Watch party invites by profile: host invites online profiles, invited users see running parties on open and can join one or dismiss
This commit is contained in:
@@ -6225,6 +6225,19 @@ const 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));
|
||||
// Invites for this device's online profile: a cheap check that only loads
|
||||
// the party chunk (which shows the invite list) when there is one.
|
||||
const peek = async () => {
|
||||
const name = data.profile && data.profile.name;
|
||||
if (!name || Lazy.loaded('feature:watch-party') || document.visibilityState === 'hidden') return;
|
||||
try {
|
||||
const j = await (await fetch(`/api/party/invites?name=${encodeURIComponent(name)}`, { headers: profileHeaders(name) })).json();
|
||||
if (j && j.ok && j.invites && j.invites.length) { await Lazy.load('feature:watch-party'); window.YT.Party.checkInvites(); }
|
||||
} catch { /* offline */ }
|
||||
};
|
||||
setTimeout(peek, 3000);
|
||||
setInterval(peek, 60_000);
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') peek(); });
|
||||
},
|
||||
};
|
||||
Lazy.onLoad('feature:watch-party', () => window.YT.Party.boot());
|
||||
|
||||
@@ -4066,6 +4066,14 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
|
||||
.party-panel.min .pt-body { display: none; }
|
||||
.pt-present { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.pt-present:empty { display: none; }
|
||||
.pt-invite { display: flex; gap: 6px; }
|
||||
.pt-invite input { flex: 1; min-width: 0; }
|
||||
.pt-uninvite { margin-left: 4px; min-width: 28px; min-height: 28px; border: 0; background: none; color: var(--text-2); font-size: 16px; cursor: pointer; }
|
||||
.pt-invites { display: grid; gap: 8px; margin-top: 4px; }
|
||||
.pt-invite-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--bg-2); }
|
||||
.pt-invite-row > div { flex: 1; min-width: 0; display: grid; gap: 2px; }
|
||||
.pt-invite-row small { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pt-invite-row .rv-primary { flex: none; min-height: 44px; }
|
||||
/* Shared presentation: fills the screen beside the party panel (below it on phones). */
|
||||
.party-present {
|
||||
position: fixed; inset: 12px 372px 12px 12px; z-index: 155;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
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: '',
|
||||
loadingId: '', restoring: false, inviteCode: '', hiddenAt: 0, present: null, presentHidden: false,
|
||||
loadingId: '', restoring: false, inviteCode: '', hiddenAt: 0, present: null, presentHidden: false, invites: [], inviteError: '',
|
||||
voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
|
||||
minimized: false,
|
||||
};
|
||||
@@ -70,6 +70,7 @@
|
||||
st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
|
||||
st.allowControl = !!m.allowControl;
|
||||
st.present = m.present || null; renderPresent();
|
||||
st.invites = m.invites || [];
|
||||
setStatus(isHost() ? 'You’re hosting' : 'Synced with the host');
|
||||
if (m.lost && isHost()) toast(`🎉 The old party was lost (server restart). New code ${m.code} — share the link again`, { duration: 8000 });
|
||||
render();
|
||||
@@ -109,6 +110,10 @@
|
||||
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 === 'invites') {
|
||||
st.invites = m.invites || []; st.inviteError = m.error || '';
|
||||
if (m.error) toast('⚠ ' + m.error);
|
||||
render();
|
||||
} else if (m.type === 'present') {
|
||||
const was = st.present && st.present.url;
|
||||
st.present = m.present || null;
|
||||
@@ -395,6 +400,8 @@
|
||||
<div class="pt-present">${st.present
|
||||
? `${st.presentHidden ? '<button class="np-btn" type="button" data-act="present-show">🖥 Show the presentation</button>' : ''}${canPresent() ? '<button class="np-btn" type="button" data-act="present">Change link</button><button class="np-btn" type="button" data-act="present-stop">Stop presenting</button>' : ''}`
|
||||
: canPresent() ? '<button class="np-btn" type="button" data-act="present">🖥 Present a document or website</button>' : ''}</div>
|
||||
${isHost() ? `<form class="pt-invite"><input class="pt-invite-name" maxlength="40" placeholder="Invite a profile by name" autocapitalize="none" autocomplete="off" spellcheck="false" /><button class="np-btn" type="submit">Invite</button></form>
|
||||
${st.invites.length ? `<div class="pt-members pt-invited">${st.invites.map((n) => `<span class="pt-member">✉ ${escapeHtml(n)} <button type="button" class="pt-uninvite" data-act="uninvite" data-profile="${escapeHtml(n)}" aria-label="Cancel the invite for ${escapeHtml(n)}">×</button></span>`).join('')}</div>` : ''}` : ''}
|
||||
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
|
||||
<div class="pt-present"><button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
|
||||
<button class="np-btn pt-land" type="button" data-act="land"${lyricsOn() ? '' : ' hidden'}>${Notes.stageLyrics.landscapeOn?.() ? '↺ Exit landscape' : '↻ Landscape lyrics'}</button></div>
|
||||
@@ -463,6 +470,7 @@
|
||||
else if (act === 'land') { Notes.stageLyrics.landscape?.(!Notes.stageLyrics.landscapeOn?.()); renderLyricsToggle(); }
|
||||
else if (act === 'leave') leave(true);
|
||||
else if (act === 'present') presentPrompt();
|
||||
else if (act === 'uninvite') send({ type: 'uninvite', profile: a.dataset.profile });
|
||||
else if (act === 'present-stop') send({ type: 'present', present: null });
|
||||
else if (act === 'present-show') { st.presentHidden = false; renderPresent(); render(); }
|
||||
});
|
||||
@@ -471,6 +479,11 @@
|
||||
});
|
||||
panel.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.target.closest('.pt-invite')) {
|
||||
const inp = panel.querySelector('.pt-invite-name'), name = inp.value.trim().toLowerCase();
|
||||
if (name) { send({ type: 'invite', profile: name }); inp.value = ''; }
|
||||
return;
|
||||
}
|
||||
const input = panel.querySelector('.pt-msg');
|
||||
const text = input.value.trim();
|
||||
if (text) { send({ type: 'chat', text }); input.value = ''; }
|
||||
@@ -485,6 +498,34 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- invites: parties that invited this device's online profile ----------
|
||||
const SEEN_KEY = 'ytpPartyInvitesSeen';
|
||||
let invitesOpen = false;
|
||||
async function checkInvites({ force = false } = {}) {
|
||||
const name = data.profile && data.profile.name;
|
||||
if (!name || inParty() || invitesOpen || document.visibilityState === 'hidden') return;
|
||||
let list = [];
|
||||
try {
|
||||
const res = await fetch(`/api/party/invites?name=${encodeURIComponent(name)}`, { headers: typeof profileHeaders === 'function' ? profileHeaders(name) : {} });
|
||||
const j = await res.json(); list = (j && j.ok && j.invites) || [];
|
||||
} catch { return; }
|
||||
let seen = []; try { seen = JSON.parse(ls.get(SEEN_KEY) || '[]'); } catch { /* none */ }
|
||||
const fresh = list.filter((i) => force || !seen.includes(`${i.code}:${i.at}`));
|
||||
if (!fresh.length || inParty()) return;
|
||||
invitesOpen = true;
|
||||
const markSeen = () => { ls.set(SEEN_KEY, JSON.stringify([...new Set([...seen, ...list.map((i) => `${i.code}:${i.at}`)])].slice(-50))); };
|
||||
const body = document.createElement('div');
|
||||
body.innerHTML = `<p class="modal-text">${list.length === 1 ? 'You’ve been invited to a watch party.' : `You’ve been invited to ${list.length} watch parties.`}</p>
|
||||
<div class="pt-invites">${list.map((i) => `<div class="pt-invite-row">
|
||||
<div><b>${escapeHtml(i.host)}’s party</b><small>${i.song ? `Playing ${escapeHtml(i.song)} · ` : ''}${i.members} ${i.members === 1 ? 'person' : 'people'}${i.hostOnline ? '' : ' · host away'}</small></div>
|
||||
<button class="rv-primary" type="button" data-join="${escapeHtml(i.code)}">Join</button></div>`).join('')}</div>`;
|
||||
body.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-join]'); if (!b) return;
|
||||
markSeen(); invitesOpen = false; closeModal(); join(b.dataset.join, myName() || name);
|
||||
});
|
||||
showModal('🎉 Watch party invites', body, [{ label: 'Dismiss', onClick: () => { markSeen(); invitesOpen = false; closeModal(); } }]);
|
||||
}
|
||||
|
||||
// ---------- presenting: a shared document / website everyone sees ----------
|
||||
const canPresent = () => inParty() && (isHost() || st.allowControl);
|
||||
// Google Slides/Docs/Drive and PDFs get their embeddable form; Slides and PDFs
|
||||
@@ -561,8 +602,11 @@
|
||||
render();
|
||||
// iOS keeps a dead socket "open" after the PWA was in the background:
|
||||
// after a long absence reconnect straight away instead of waiting for onclose.
|
||||
setTimeout(() => checkInvites(), 2500);
|
||||
setInterval(() => checkInvites(), 60_000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') { st.hiddenAt = Date.now(); return; }
|
||||
checkInvites();
|
||||
if (!inParty() || st.detached) return;
|
||||
const ws = st.ws;
|
||||
if (!ws || ws.readyState !== 1 || Date.now() - st.hiddenAt > 20000) {
|
||||
@@ -586,7 +630,7 @@
|
||||
|
||||
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),
|
||||
open, boot, guestActive, intercept, renderLyricsToggle, checkInvites, 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),
|
||||
|
||||
Reference in New Issue
Block a user