Lazy-load watch-party sessions behind synchronous playback hooks
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
474
frontend/app.js
474
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 = `
|
||||
<p class="modal-text">Watch together: everyone's player follows the host, with chat and optional voice.</p>
|
||||
${nameOk ? '' : '<label class="pt-row">Your name <input class="pt-name" maxlength="40" placeholder="e.g. Rex" /></label>'}
|
||||
<button class="rv-primary" type="button" data-act="start">Start a party</button>
|
||||
<div class="pt-or">or join one</div>
|
||||
<div class="pt-row"><input class="pt-code" maxlength="7" placeholder="CODE" autocapitalize="characters" /><button class="np-btn" type="button" data-act="join">Join</button></div>`;
|
||||
$('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 ? `<div class="pt-share"><code>${escapeHtml(link())}</code><button class="np-btn" type="button" data-act="copy">Copy</button>${navigator.share ? '<button class="np-btn" type="button" data-act="share">↗</button>' : ''}</div>` : ''}
|
||||
${!isHost() && s ? `<div class="pt-now">▶ ${escapeHtml(s.title)}</div>` : ''}
|
||||
${!isHost() && st.detached ? '<button class="rv-primary" type="button" data-act="resync">↻ Re-sync with the host</button>' : ''}
|
||||
<div class="pt-members">${st.members.map((m) => `<span class="pt-member${m.pid === pid ? ' me' : ''}">${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}</span>`).join('')}</div>
|
||||
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
|
||||
<button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
|
||||
<div class="pt-voice">
|
||||
<button class="np-btn${st.voice.on ? ' on' : ''}" type="button" data-act="voice">${st.voice.on ? '🎤 Leave voice' : '🎤 Join voice'}</button>
|
||||
${st.voice.on ? `<button class="np-btn" type="button" data-act="mute">${st.voice.muted ? '🔇 Unmute' : '🔈 Mute'}</button>` : ''}
|
||||
<span class="pt-voice-n">${st.voice.on ? `${st.voice.pcs.size} connected` : ''}</span>
|
||||
</div>
|
||||
<div class="pt-chat" id="ptChat"></div>
|
||||
<form class="pt-send"><input class="pt-msg" maxlength="500" placeholder="Message…" autocomplete="off" /><button class="np-btn" type="submit">Send</button></form>
|
||||
<button class="pt-leave" type="button" data-act="leave">${isHost() ? 'End the party' : 'Leave the party'}</button>`;
|
||||
const dj = document.createElement('div'); panel.querySelector('.pt-body').append(dj);
|
||||
if (Lazy.loaded('feature:party-dj')) PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } });
|
||||
renderChat();
|
||||
}
|
||||
function renderChat() {
|
||||
const box = $('ptChat');
|
||||
if (!box) return;
|
||||
box.innerHTML = st.chat.map((c) => c.sys
|
||||
? `<div class="pt-sys">${escapeHtml(c.text)}</div>`
|
||||
: `<div class="pt-line${c.pid === pid ? ' me' : ''}"><b>${escapeHtml(c.from)}</b> ${escapeHtml(c.text)}</div>`).join('');
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -332,6 +332,14 @@
|
||||
"files": [
|
||||
"/remote-client.js"
|
||||
]
|
||||
},
|
||||
"feature:watch-party": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/watch-party-client.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="preload" href="fonts/BricolageGrotesque-latin.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="preload" href="fonts/HankenGrotesk-latin.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<script type="application/json" id="ytp-assets">{"buildTag":"static","groups":{"core":{"contract":1,"eager":true,"files":["/index.html","/styles.css","/service-ui.css","/refresh.css","/layout-base.css","/recommendations.css","/flag-ui.js","/async-guard.js","/visible-timer.js","/asset-sync-core.js","/sw-update.js","/fingerprint.js","/opfs.js","/opfs-worker.js","/lyrics-core.js","/lyrics-display.js","/sha256.js","/resume-core.js","/loudness-wasm.js","/loudness.js","/sha256-wasm.js","/settings-sections.js","/related-videos.js","/related-videos.css","/audio-continuity.js","/fullscreen-orientation.js","/fullscreen-orientation.css","/download-actions.js","/download-actions.css","/download-errors.js","/downloads.js","/downloads-page.js","/offline-pages.css","/saved-page.js","/server-backfill.js","/saved-page.css","/prewarm-next.js","/settings-sections.css","/app.js","/manifest.webmanifest","/icons/icon-192.png","/icons/icon-512.png","/fonts/fonts.css","/fonts/BricolageGrotesque-latin-ext.woff2","/fonts/BricolageGrotesque-latin.woff2","/fonts/HankenGrotesk-latin-ext.woff2","/fonts/HankenGrotesk-latin.woff2","/fonts/JetBrainsMono-latin-ext.woff2","/fonts/JetBrainsMono-latin.woff2","/lazy.js","/feature-bootstrap.js","/eq-parameters.js","/assets.json","/device-db.js","/shell-core.js","/views-core.js","/section-rail.js"],"background":false},"layout:glass-stage":{"contract":1,"eager":false,"files":["/theme-glass.css","/theme-glass-panel.css","/glass-panel-layout.js"],"background":true,"dependencies":["feature:layout-controls"]},"layout:classic":{"contract":1,"eager":false,"files":["/theme-classic.css"],"background":true},"layout:bento-hub":{"contract":1,"eager":false,"files":["/theme-bento-hub.css","/bento-hub.js","/fonts/PlusJakartaSans-latin.woff2","/fonts/PlusJakartaSans-latin-ext.woff2"],"background":true,"dependencies":["feature:layout-controls"]},"layout:f7-swipe":{"contract":1,"eager":false,"files":["/theme-f7.css","/vendor/framework7-swipe.min.js","/f7-layout.js"],"background":true},"feature:piano":{"contract":1,"eager":false,"files":["/piano-core.js","/piano.js","/piano-engine.mjs"],"background":true},"feature:midi":{"contract":1,"eager":false,"files":["/midi.js"],"background":true},"feature:party-dj":{"contract":1,"eager":false,"files":["/party-dj.js"],"background":true},"feature:lyrics-window":{"contract":1,"eager":false,"files":["/lyrics-window.js"],"background":true},"feature:lower-third":{"contract":1,"eager":false,"files":["/lower-third.js","/lower-third.css"],"background":true},"feature:vocal-reducer":{"contract":1,"eager":false,"files":["/vocal-reducer.js"],"background":true},"feature:eq-core":{"contract":1,"eager":false,"files":["/eq-core.js"],"background":true},"feature:setlist-import":{"contract":1,"eager":false,"files":["/setlist-import.js"],"background":true},"feature:car-mode":{"contract":1,"eager":false,"files":["/car-mode.js","/car-mode.css"],"background":true},"feature:p2p":{"contract":1,"eager":false,"files":["/p2p-core.js","/p2p-client.js","/p2p-transfer.js","/p2p-recv-worker.js","/hash-worker.js"],"background":true},"feature:direct":{"contract":1,"eager":false,"files":["/direct-protocol.js","/direct-stream.js","/direct-media.js","/direct-media.css","/direct-recv-worker.js"],"background":true,"dependencies":["feature:p2p"]},"feature:video-edit":{"contract":1,"eager":false,"files":["/video-edit.js"],"background":true},"feature:stats":{"contract":1,"eager":false,"files":["/stats-core.js"],"background":true},"feature:export":{"contract":1,"eager":false,"files":["/export.js"],"background":true},"feature:playlist":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js"],"background":false},"feature:settings":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/settings-sections.js","/settings-sections.css","/views-core.js"],"background":false},"feature:service-mode":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/service-ui.css"],"background":false},"feature:lyrics-editor":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/lyrics-core.js","/lyrics-display.js"],"background":false},"feature:layout-controls":{"contract":1,"eager":false,"background":true,"files":["/theme-glass-controls.css","/glass-controls.js"]},"layout:sanctuary-stage":{"contract":1,"eager":false,"background":true,"files":[]},"layout:liturgical":{"contract":1,"eager":false,"background":true,"files":[]},"layout:living-hymnal":{"contract":1,"eager":false,"background":true,"files":[]},"layout:broadcaster":{"contract":1,"eager":false,"background":true,"files":[]},"layout:contemplative":{"contract":1,"eager":false,"background":true,"files":[]},"feature:presenter":{"contract":1,"eager":false,"background":true,"files":["/presenter.js"]},"feature:remote-client":{"contract":1,"eager":false,"background":true,"files":["/remote-client.js"]}},"files":{}}</script>
|
||||
<script type="application/json" id="ytp-assets">{"buildTag":"static","groups":{"core":{"contract":1,"eager":true,"files":["/index.html","/styles.css","/service-ui.css","/refresh.css","/layout-base.css","/recommendations.css","/flag-ui.js","/async-guard.js","/visible-timer.js","/asset-sync-core.js","/sw-update.js","/fingerprint.js","/opfs.js","/opfs-worker.js","/lyrics-core.js","/lyrics-display.js","/sha256.js","/resume-core.js","/loudness-wasm.js","/loudness.js","/sha256-wasm.js","/settings-sections.js","/related-videos.js","/related-videos.css","/audio-continuity.js","/fullscreen-orientation.js","/fullscreen-orientation.css","/download-actions.js","/download-actions.css","/download-errors.js","/downloads.js","/downloads-page.js","/offline-pages.css","/saved-page.js","/server-backfill.js","/saved-page.css","/prewarm-next.js","/settings-sections.css","/app.js","/manifest.webmanifest","/icons/icon-192.png","/icons/icon-512.png","/fonts/fonts.css","/fonts/BricolageGrotesque-latin-ext.woff2","/fonts/BricolageGrotesque-latin.woff2","/fonts/HankenGrotesk-latin-ext.woff2","/fonts/HankenGrotesk-latin.woff2","/fonts/JetBrainsMono-latin-ext.woff2","/fonts/JetBrainsMono-latin.woff2","/lazy.js","/feature-bootstrap.js","/eq-parameters.js","/assets.json","/device-db.js","/shell-core.js","/views-core.js","/section-rail.js"],"background":false},"layout:glass-stage":{"contract":1,"eager":false,"files":["/theme-glass.css","/theme-glass-panel.css","/glass-panel-layout.js"],"background":true,"dependencies":["feature:layout-controls"]},"layout:classic":{"contract":1,"eager":false,"files":["/theme-classic.css"],"background":true},"layout:bento-hub":{"contract":1,"eager":false,"files":["/theme-bento-hub.css","/bento-hub.js","/fonts/PlusJakartaSans-latin.woff2","/fonts/PlusJakartaSans-latin-ext.woff2"],"background":true,"dependencies":["feature:layout-controls"]},"layout:f7-swipe":{"contract":1,"eager":false,"files":["/theme-f7.css","/vendor/framework7-swipe.min.js","/f7-layout.js"],"background":true},"feature:piano":{"contract":1,"eager":false,"files":["/piano-core.js","/piano.js","/piano-engine.mjs"],"background":true},"feature:midi":{"contract":1,"eager":false,"files":["/midi.js"],"background":true},"feature:party-dj":{"contract":1,"eager":false,"files":["/party-dj.js"],"background":true},"feature:lyrics-window":{"contract":1,"eager":false,"files":["/lyrics-window.js"],"background":true},"feature:lower-third":{"contract":1,"eager":false,"files":["/lower-third.js","/lower-third.css"],"background":true},"feature:vocal-reducer":{"contract":1,"eager":false,"files":["/vocal-reducer.js"],"background":true},"feature:eq-core":{"contract":1,"eager":false,"files":["/eq-core.js"],"background":true},"feature:setlist-import":{"contract":1,"eager":false,"files":["/setlist-import.js"],"background":true},"feature:car-mode":{"contract":1,"eager":false,"files":["/car-mode.js","/car-mode.css"],"background":true},"feature:p2p":{"contract":1,"eager":false,"files":["/p2p-core.js","/p2p-client.js","/p2p-transfer.js","/p2p-recv-worker.js","/hash-worker.js"],"background":true},"feature:direct":{"contract":1,"eager":false,"files":["/direct-protocol.js","/direct-stream.js","/direct-media.js","/direct-media.css","/direct-recv-worker.js"],"background":true,"dependencies":["feature:p2p"]},"feature:video-edit":{"contract":1,"eager":false,"files":["/video-edit.js"],"background":true},"feature:stats":{"contract":1,"eager":false,"files":["/stats-core.js"],"background":true},"feature:export":{"contract":1,"eager":false,"files":["/export.js"],"background":true},"feature:playlist":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js"],"background":false},"feature:settings":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/settings-sections.js","/settings-sections.css","/views-core.js"],"background":false},"feature:service-mode":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/service-ui.css"],"background":false},"feature:lyrics-editor":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/lyrics-core.js","/lyrics-display.js"],"background":false},"feature:layout-controls":{"contract":1,"eager":false,"background":true,"files":["/theme-glass-controls.css","/glass-controls.js"]},"layout:sanctuary-stage":{"contract":1,"eager":false,"background":true,"files":[]},"layout:liturgical":{"contract":1,"eager":false,"background":true,"files":[]},"layout:living-hymnal":{"contract":1,"eager":false,"background":true,"files":[]},"layout:broadcaster":{"contract":1,"eager":false,"background":true,"files":[]},"layout:contemplative":{"contract":1,"eager":false,"background":true,"files":[]},"feature:presenter":{"contract":1,"eager":false,"background":true,"files":["/presenter.js"]},"feature:remote-client":{"contract":1,"eager":false,"background":true,"files":["/remote-client.js"]},"feature:watch-party":{"contract":1,"eager":false,"background":true,"files":["/watch-party-client.js"]}},"files":{}}</script>
|
||||
<script src="lazy.js"></script>
|
||||
<link rel="stylesheet" href="fonts/fonts.css" />
|
||||
<link rel="preconnect" href="https://i.ytimg.com" crossorigin />
|
||||
|
||||
@@ -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',
|
||||
|
||||
458
frontend/watch-party-client.js
Normal file
458
frontend/watch-party-client.js
Normal file
@@ -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 = `
|
||||
<p class="modal-text">Watch together: everyone's player follows the host, with chat and optional voice.</p>
|
||||
${nameOk ? '' : '<label class="pt-row">Your name <input class="pt-name" maxlength="40" placeholder="e.g. Rex" /></label>'}
|
||||
<button class="rv-primary" type="button" data-act="start">Start a party</button>
|
||||
<div class="pt-or">or join one</div>
|
||||
<div class="pt-row"><input class="pt-code" maxlength="7" placeholder="CODE" autocapitalize="characters" /><button class="np-btn" type="button" data-act="join">Join</button></div>`;
|
||||
$('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 ? `<div class="pt-share"><code>${escapeHtml(link())}</code><button class="np-btn" type="button" data-act="copy">Copy</button>${navigator.share ? '<button class="np-btn" type="button" data-act="share">↗</button>' : ''}</div>` : ''}
|
||||
${!isHost() && s ? `<div class="pt-now">▶ ${escapeHtml(s.title)}</div>` : ''}
|
||||
${!isHost() && st.detached ? '<button class="rv-primary" type="button" data-act="resync">↻ Re-sync with the host</button>' : ''}
|
||||
<div class="pt-members">${st.members.map((m) => `<span class="pt-member${m.pid === pid ? ' me' : ''}">${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}</span>`).join('')}</div>
|
||||
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
|
||||
<button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
|
||||
<div class="pt-voice">
|
||||
<button class="np-btn${st.voice.on ? ' on' : ''}" type="button" data-act="voice">${st.voice.on ? '🎤 Leave voice' : '🎤 Join voice'}</button>
|
||||
${st.voice.on ? `<button class="np-btn" type="button" data-act="mute">${st.voice.muted ? '🔇 Unmute' : '🔈 Mute'}</button>` : ''}
|
||||
<span class="pt-voice-n">${st.voice.on ? `${st.voice.pcs.size} connected` : ''}</span>
|
||||
</div>
|
||||
<div class="pt-chat" id="ptChat"></div>
|
||||
<form class="pt-send"><input class="pt-msg" maxlength="500" placeholder="Message…" autocomplete="off" /><button class="np-btn" type="submit">Send</button></form>
|
||||
<button class="pt-leave" type="button" data-act="leave">${isHost() ? 'End the party' : 'Leave the party'}</button>`;
|
||||
const dj = document.createElement('div'); panel.querySelector('.pt-body').append(dj);
|
||||
if (Lazy.loaded('feature:party-dj')) PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } });
|
||||
renderChat();
|
||||
}
|
||||
function renderChat() {
|
||||
const box = $('ptChat');
|
||||
if (!box) return;
|
||||
box.innerHTML = st.chat.map((c) => c.sys
|
||||
? `<div class="pt-sys">${escapeHtml(c.text)}</div>`
|
||||
: `<div class="pt-line${c.pid === pid ? ' me' : ''}"><b>${escapeHtml(c.from)}</b> ${escapeHtml(c.text)}</div>`).join('');
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user