dismissed) showGuestAnnouncement(s.announcement.text);
}
// Group vote
const clearBtn = document.getElementById('clear-gvote-btn');
if (s.group_vote) {
const gvTs = s.group_vote.ts || 0;
localStorage.setItem('bash_gvote_ts', gvTs);
showGuestGroupVote(s.group_vote.question);
// Restore disabled state only if voted on THIS question
const stored = JSON.parse(localStorage.getItem('bash_my_gvote') || 'null');
if (stored && stored.ts === gvTs) updateGvoteDisplay(s.group_vote.yes, s.group_vote.no);
else {
// New question — re-enable buttons
['gv-yes-btn','gv-no-btn'].forEach(id => { const b = document.getElementById(id); if (b) b.disabled = false; });
const res = document.getElementById('gv-result-text'); if (res) res.classList.add('gone');
}
if (clearBtn) clearBtn.style.display = '';
} else {
if (clearBtn) clearBtn.style.display = 'none';
const card = document.getElementById('guest-gvote'); if (card) card.classList.add('gone');
}
// Photos — skip update briefly after a reaction to preserve optimistic state
if (s.photos !== undefined) {
const msSinceReaction = Date.now() - (window._lastReactionAt || 0);
if (msSinceReaction > 4000) { // 4s headroom for server write + next poll
// Safe to update — no recent reaction
photos = s.photos;
mediaList = s.photos;
renderPhotos(s.photos);
updateRecapMedia(s.photos);
if (!document.getElementById('lightbox')?.classList.contains('gone')) {
// Re-find the open photo by filename (index may have shifted)
if (lbFilename) {
const newIdx = mediaList.findIndex(p =>
(p.filename || p.url?.split('/').pop()) === lbFilename);
if (newIdx > -1) lbIndex = newIdx;
}
const updated = mediaList[lbIndex];
if (updated) renderLightbox();
}
}
// Always update non-photo parts of state regardless
}
}
function se(id, val) { const e = document.getElementById(id); if (e) e.textContent = val; }
function renderRsvpList(list) {
const el = document.getElementById('rsvp-name-list'); if (!el) return;
el.innerHTML = list.slice(-8).reverse().map(r => {
const icon = r.status==='going'?'🎉':r.status==='maybe'?'🤔':'😔';
return `
${icon} ${r.name}
`;
}).join('');
}
// ── RSVP + ACCOUNT ───────────────────────────────────────
async function submitRsvp(status) {
const name = document.getElementById('rsvp-name-input').value.trim();
const pin = document.getElementById('rsvp-pin-input').value.trim();
if (!name) { showToast('⚠️ Enter your name first'); return; }
if (pin.length < 4) { showToast('⚠️ PIN must be at least 4 digits'); return; }
const r = await api('register_guest', { name, pin, rsvp: status });
if (!r.ok) { showToast('⚠️ ' + r.error); return; }
// If server returned a voter_id, use it (may differ from local if server generated one)
if (r.voter_id) localStorage.setItem('bash_voter_id', r.voter_id);
localStorage.setItem('bash_my_rsvp', status);
localStorage.setItem('bash_guest_name', r.name || name);
showApp();
const n = r.name || name;
showToast(status==='going' ? `🎉 See you tonight, ${n}!` : status==='maybe' ? '🤞 Hope you make it!' : '😔 Noted!');
}
async function loginGuest() {
const name = document.getElementById('login-name').value.trim();
const pin = document.getElementById('login-pin').value.trim();
const errEl = document.getElementById('login-error');
errEl.style.display = 'none';
if (!name || !pin) { errEl.textContent = 'Name and PIN are required'; errEl.style.display = 'block'; return; }
const r = await api('auth_guest', { name, pin });
if (!r.ok) {
errEl.textContent = r.error;
errEl.style.display = 'block';
document.getElementById('login-pin').value = '';
return;
}
// Restore their identity
localStorage.setItem('bash_voter_id', r.voter_id);
localStorage.setItem('bash_my_rsvp', r.rsvp || 'going');
localStorage.setItem('bash_guest_name', r.name);
closeModal('login-modal');
document.getElementById('rsvp-overlay').classList.add('gone');
showApp();
showToast(`👋 Welcome back, ${r.name}!`);
}
function updateMyRsvpDisplay() {
const status = localStorage.getItem('bash_my_rsvp');
const el = document.getElementById('my-rsvp-display'); if (!el) return;
if (!status) return;
const map = { going:{label:"You're Going! 🎉",color:'var(--lime)'}, maybe:{label:"You're a Maybe 🤔",color:'var(--amber)'}, no:{label:"Can't Make It 😔",color:'var(--muted)'} };
const m = map[status] || {};
el.innerHTML = `
${m.label} `;
}
// ── HOST AUTH ────────────────────────────────────────────
function requestHostView() {
if (isHostAuth) { setPersp('host'); return; }
showPinModal();
}
function showPinModal() {
document.getElementById('pin-input').value = '';
document.getElementById('pin-error').style.display = 'none';
document.getElementById('pin-modal-bg').classList.remove('gone');
setTimeout(() => document.getElementById('pin-input').focus(), 100);
}
function closePinModal() { document.getElementById('pin-modal-bg').classList.add('gone'); }
async function checkPin() {
const entered = document.getElementById('pin-input').value.trim();
if (entered !== CFG.hostPin) {
document.getElementById('pin-error').style.display = 'block';
document.getElementById('pin-input').value = '';
document.getElementById('pin-input').focus();
return;
}
isHostAuth = true;
closePinModal();
// ── Full identity switch: become the Host account ──
const hostVid = 'v-host'; // simple constant — same on client & server, no mismatch
const hostName = CFG.hostName || 'Host';
localStorage.setItem('bash_voter_id', hostVid);
localStorage.setItem('bash_guest_name', hostName);
localStorage.setItem('bash_host_mode', '1');
// Register Host account — pass voter_id so server stores the exact same one
await api('ensure_host_account', { host_name: hostName, pin: CFG.hostPin, voter_id: hostVid });
// If app not shown yet (entering from RSVP screen), show it now
const appEl = document.getElementById('app');
if (!appEl || appEl.classList.contains('gone')) {
// Mark as "logged in" so RSVP screen is skipped
localStorage.setItem('bash_my_rsvp', 'going');
showApp();
}
setPersp('host');
updateHostUI();
updateNameChip();
showToast('🔓 Logged in as Host');
}
function hostLogout() {
switchToGuest();
closeModal('settings-bg');
}
function switchToGuest() {
if (!isHostAuth) return; // already guest
isHostAuth = false;
// ── Completely clear identity — guest must log in again ──
localStorage.removeItem('bash_host_mode');
localStorage.removeItem('bash_voter_id');
localStorage.removeItem('bash_guest_voter_id');
localStorage.removeItem('bash_guest_name');
localStorage.removeItem('bash_my_rsvp');
localStorage.removeItem('bash_my_mvp_vote');
localStorage.removeItem('bash_my_next_rsvp');
localStorage.removeItem('bash_my_gvote');
localStorage.removeItem('bash_gvote_ts');
localStorage.removeItem('bash_announce_dismissed');
updateHostUI();
// Hide app, show RSVP screen (fresh login)
document.getElementById('app').classList.add('gone');
document.getElementById('bnav').classList.add('gone');
document.getElementById('rsvp-host-name').textContent = CFG.hostName + "'s Party";
document.getElementById('rsvp-overlay').classList.remove('gone');
// Clear PIN/name inputs
const ni = document.getElementById('rsvp-name-input'); if (ni) ni.value = '';
const pi = document.getElementById('rsvp-pin-input'); if (pi) pi.value = '';
}
function updateHostUI() {
const chip = document.getElementById('host-chip');
if (chip) chip.classList.toggle('gone', !isHostAuth);
document.getElementById('gear-btn').classList.toggle('gone', !isHostAuth);
document.getElementById('pb-host').style.opacity = isHostAuth ? '1' : '0.5';
updateNameChip();
}
function updateNameChip() {
const bar = document.getElementById('guest-name-bar');
const lbl = document.getElementById('guest-name-label');
if (!bar || !lbl) return;
const name = localStorage.getItem('bash_guest_name');
if (name) {
bar.classList.remove('gone');
lbl.textContent = name;
// Different colour for host vs guest
if (isHostAuth) {
bar.style.background = 'rgba(255,45,120,.08)';
bar.style.borderColor = 'rgba(255,45,120,.25)';
lbl.style.color = 'var(--pink)';
} else {
bar.style.background = 'rgba(184,255,87,.07)';
bar.style.borderColor = 'rgba(184,255,87,.2)';
lbl.style.color = 'var(--lime)';
}
} else {
bar.classList.add('gone');
}
}
// ── SETTINGS ─────────────────────────────────────────────
function openSettings() {
if (!isHostAuth) { showToast('🔐 Host access required'); return; }
document.getElementById('cfg-user').value = CFG.lfmUser;
document.getElementById('cfg-key').value = CFG.lfmKey;
document.getElementById('cfg-claude').value = CFG.claudeKey;
document.getElementById('cfg-name').value = CFG.hostName;
document.getElementById('cfg-pin').value = CFG.hostPin;
document.getElementById('cfg-url').value = CFG.partyUrl !== window.location.href ? CFG.partyUrl : '';
document.getElementById('settings-bg').classList.remove('gone');
}
function saveSettings() {
if (document.getElementById('cfg-user').value.trim()) CFG.lfmUser = document.getElementById('cfg-user').value.trim();
if (document.getElementById('cfg-key').value.trim()) CFG.lfmKey = document.getElementById('cfg-key').value.trim();
CFG.claudeKey = document.getElementById('cfg-claude').value.trim();
if (document.getElementById('cfg-name').value.trim()) CFG.hostName = document.getElementById('cfg-name').value.trim();
if (document.getElementById('cfg-pin').value.trim()) CFG.hostPin = document.getElementById('cfg-pin').value.trim();
const u = document.getElementById('cfg-url').value.trim();
if (u) CFG.partyUrl = u.startsWith('http') ? u : 'https://' + u;
saveCfg(); closeModal('settings-bg'); updateQRDisplay();
showToast('✅ Settings saved');
}
function clearPhotos() { photos = []; savePhotos(); renderPhotos(); showToast('🗑 Photos cleared'); closeModal('settings-bg'); }
async function resetAll() {
if (!confirm('Reset ALL party data on the server? This cannot be undone.')) return;
await api('reset_data', { pin: CFG.hostPin });
localStorage.clear(); location.reload();
}
// ── MODALS ────────────────────────────────────────────────
function openModal(id) { document.getElementById(id).classList.remove('gone'); }
function closeModal(id) { document.getElementById(id).classList.add('gone'); }
// ── QR ────────────────────────────────────────────────────
function initQR() {
if (!CFG.partyUrl) CFG.partyUrl = window.location.href;
updateQRDisplay();
}
function updateQRDisplay() {
const url = CFG.partyUrl;
const src = `https://api.qrserver.com/v1/create-qr-code/?size=150x150&color=111111&bgcolor=ffffff&qzone=1&data=${encodeURIComponent(url)}`;
['qr-img','g-qr-img'].forEach(id => { const e = document.getElementById(id); if (e) e.src = src; });
const el = document.getElementById('join-link-display');
if (el) el.textContent = url.length > 44 ? url.slice(0, 44) + '…' : url;
}
function setUrl() {
const val = document.getElementById('url-input').value.trim(); if (!val) return;
CFG.partyUrl = val.startsWith('http') ? val : 'https://' + val;
saveCfg(); updateQRDisplay(); document.getElementById('url-input').value = '';
showToast('✅ QR updated!');
}
function copyLink() {
navigator.clipboard.writeText(CFG.partyUrl).then(() => showToast('📋 Link copied!')).catch(() => showToast('Link: ' + CFG.partyUrl));
}
// ── HOST CONTROLS ─────────────────────────────────────────
async function sendAnnouncement() {
const text = document.getElementById('announce-text').value.trim();
if (!text) return;
const r = await api('set_announcement', { text, pin: CFG.hostPin });
if (r.ok) {
closeModal('announce-modal-bg');
document.getElementById('announce-text').value = '';
showGuestAnnouncement(text);
showToast('📢 Announcement sent!');
} else { showToast('⚠️ ' + r.error); }
}
function dismissAnnouncement() {
document.getElementById('guest-announce').classList.add('gone');
localStorage.setItem('bash_announce_dismissed', Date.now());
}
function showGuestAnnouncement(text) {
const el = document.getElementById('guest-announce'), t = document.getElementById('guest-announce-text');
if (!el || !t) return;
t.textContent = text; el.classList.remove('gone');
const btn = document.getElementById('clear-announce-btn');
if (btn && isHostAuth) btn.style.display = '';
}
async function launchGroupVote() {
const q = document.getElementById('gvote-q').value.trim(); if (!q) return;
const r = await api('set_group_vote', { question: q, pin: CFG.hostPin });
if (r.ok) {
closeModal('gvote-setup-modal');
document.getElementById('gvote-q').value = '';
showGuestGroupVote(q);
showToast('🗳 Group vote started!');
} else { showToast('⚠️ ' + r.error); }
}
function showGuestGroupVote(q) {
const card = document.getElementById('guest-gvote'), qel = document.getElementById('guest-gvote-q');
if (!card || !qel) return;
qel.textContent = q; card.classList.remove('gone');
}
async function castGroupVote(choice) {
// Check if voted on THIS question (matched by ts)
const stored = JSON.parse(localStorage.getItem('bash_my_gvote') || 'null');
const curTs = parseInt(localStorage.getItem('bash_gvote_ts') || '0');
if (stored && stored.ts === curTs) { showToast('Already voted!'); return; }
const r = await api('cast_group_vote', { choice });
if (r.ok && !r.duplicate) {
localStorage.setItem('bash_my_gvote', JSON.stringify({ choice, ts: curTs }));
updateGvoteDisplay(r.yes, r.no);
showToast(choice === 'yes' ? '👍 You voted Yes!' : '👎 You voted No!');
} else if (r.duplicate) {
localStorage.setItem('bash_my_gvote', JSON.stringify({ choice: r.choice || choice, ts: curTs }));
showToast('Already voted!');
}
}
function updateGvoteDisplay(yes, no) {
['gv-yes-btn','gv-no-btn'].forEach(id => { const b = document.getElementById(id); if (b) b.disabled = true; });
const res = document.getElementById('gv-result-text');
if (res) { res.textContent = `Results: 👍 ${yes ?? 0} Yes · 👎 ${no ?? 0} No`; res.classList.remove('gone'); }
}
// ── MVP ───────────────────────────────────────────────────
async function saveNominees() {
const names = document.getElementById('nominees-input').value.trim().split('\n').map(n => n.trim()).filter(Boolean);
if (!names.length) return;
const r = await api('set_nominees', { nominees: names, pin: CFG.hostPin });
if (r.ok) {
closeModal('mvp-setup-modal');
document.getElementById('nominees-input').value = '';
showToast('⭐ Nominees saved!');
pollState();
} else { showToast('⚠️ ' + r.error); }
}
function updateMvpFromState(mvp) {
mvpNominees = mvp.nominees || []; // keep in sync for modal pre-fill
updateMvpVoteList(mvp);
updateMvpResults(mvp);
}
function updateMvpVoteList(mvp) {
const el = document.getElementById('mvp-vote-list'); if (!el) return;
const nominees = mvp.nominees || [];
const voterIds = mvp.voter_ids || {};
const myVote = localStorage.getItem('bash_my_mvp_vote');
const voterId = getVoterId();
if (!nominees.length) {
el.innerHTML = `
No guests have joined yet
`;
return;
}
const myActualVote = myVote || voterIds[voterId];
// Already voted: show current selection highlighted + allow change
if (myActualVote) {
const gv = document.getElementById('g-mvp-voted'); if (gv) gv.textContent = myActualVote;
// Fall through to render nominees — current vote will be highlighted
}
// Show nominees — current vote highlighted, others tappable to change
const myGName = localStorage.getItem('bash_guest_name') || '';
el.innerHTML = nominees.map(name => {
const isSelf = name === myGName;
const isChosen = name === myActualVote;
const clickFn = isSelf
? "showToast(\"That\'s you — can\'t vote for yourself!\")"
: `voteMvp('${name.replace(/'/g, "\\'")}')`;
return `
${name[0].toUpperCase()}
${name}
${isChosen ? '
Your vote — tap to change
'
: isSelf ? '
That\'s you
' : ''}
${isChosen ? '⭐' : isSelf ? '—' : '›'}
`;
}).join('');
}
async function voteMvp(name) {
const r = await api('vote_mvp', { nominee: name });
if (r.same) { showToast('Already voting for ' + name); return; }
if (r.ok) {
const prev = localStorage.getItem('bash_my_mvp_vote');
localStorage.setItem('bash_my_mvp_vote', name);
showToast(prev && prev !== name ? `🔄 Changed vote to ${name}` : `⭐ Voted for ${name}!`);
pollState();
} else { showToast('⚠️ ' + (r.error || 'Vote failed')); }
}
function updateMvpResults(mvp) {
const el = document.getElementById('mvp-podium'); if (!el) return;
const nominees = mvp.nominees || [];
const votes = mvp.votes || {};
const hasVotes = Object.keys(votes).length > 0 && Object.values(votes).some(v => v > 0);
if (!nominees.length) {
el.innerHTML = `
No guests have joined yet
`;
return;
}
if (!hasVotes) {
el.innerHTML = `
No votes yet — guests vote in the Capsule tab
`;
return;
}
const sorted = [...nominees]
.filter(n => (votes[n] || 0) > 0)
.sort((a, b) => (votes[b] || 0) - (votes[a] || 0));
if (!sorted.length) {
el.innerHTML = `
No votes cast yet
`;
return;
}
const medals = ['🥇','🥈','🥉'];
const heights = ['96px','72px','56px'];
const colors = ['var(--amber)','#b0bec5','#cd7f32'];
const top3 = sorted.slice(0, 3);
// Reorder for podium: 2nd | 1st | 3rd
const order = top3.length === 1 ? [0] : top3.length === 2 ? [1,0] : [1,0,2];
const podiumItems = order.map(i => ({ name: top3[i], rank: i, votes: votes[top3[i]] || 0 }));
el.innerHTML = `
${podiumItems.map(p => `
${medals[p.rank]}
${p.name[0].toUpperCase()}
${p.name}
${p.votes} vote${p.votes!==1?'s':''}
#${p.rank+1}
`).join('')}
${sorted.length > 3 ? `
${sorted.slice(3).map((name, i) => `
${i+4}
${name[0].toUpperCase()}
${name}
${votes[name] || 0}
`).join('')}
` : ''}`;
}
// ── NEXT PARTY RSVP ──────────────────────────────────────
function openNextPartyModal() {
if (localStorage.getItem('bash_my_next_rsvp')) { showToast('Already on the list! 🎉'); return; }
const name = localStorage.getItem('bash_guest_name') || '';
document.getElementById('next-party-name').value = name;
openModal('next-party-modal');
}
async function confirmNextParty() {
const name = document.getElementById('next-party-name').value.trim() || localStorage.getItem('bash_guest_name') || 'A Guest';
const r = await api('add_next_party', { name }); // server will use account name if voter_id known
if (r.ok && !r.duplicate) {
localStorage.setItem('bash_my_next_rsvp', 'yes');
closeModal('next-party-modal');
const btn = document.getElementById('next-party-btn'); if (btn) btn.style.display = 'none';
document.getElementById('next-confirmed-msg').classList.remove('gone');
showToast(`🎉 ${name}, you're on the list!`);
pollState();
} else { showToast(r.duplicate ? '🎉 Already on the list!' : '⚠️ ' + r.error); }
}
function updateNextPartyListFromState(list) {
const el = document.getElementById('next-party-list'); if (!el) return;
if (!list.length) { el.innerHTML = '
No next-party RSVPs yet
'; return; }
el.innerHTML = list.map(r => `
🎉 ${r.name} is in!
`).join('');
}
// ── LAST.FM ───────────────────────────────────────────────
async function lfm(params) {
const p = new URLSearchParams({ ...params, api_key: CFG.lfmKey, format: 'json' });
const r = await fetch(`${LFM}?${p}`);
return r.json();
}
function getArt(imgs, size = 'large') {
if (!imgs) return '';
return (imgs.find(i => i.size === size) || imgs[imgs.length - 1])?.['#text'] || '';
}
function fmtN(n) { const x = parseInt(n || 0); if (x > 1e6) return (x/1e6).toFixed(1)+'M'; if (x > 1e3) return Math.round(x/1e3)+'k'; return x; }
async function fetchNP() {
if (!CFG.lfmUser || !CFG.lfmKey) return;
try {
const d = await lfm({ method: 'user.getRecentTracks', user: CFG.lfmUser, limit: 8, extended: 1 });
const raw = d?.recenttracks?.track;
const tracks = Array.isArray(raw) ? raw : (raw ? [raw] : []);
if (!tracks.length) { renderNPEmpty(); return; }
const np = tracks[0]?.['@attr']?.nowplaying === 'true' ? tracks[0] : null;
renderNP(np); renderRecent(tracks.slice(np ? 1 : 0, 7));
if (np) { songsPlayed++; se('scrobble-count', songsPlayed); }
} catch(e) { renderNPEmpty(); }
}
function renderNP(t) {
const h = document.getElementById('np-wrap'), g = document.getElementById('gnp');
if (!t) { renderNPEmpty(); return; }
const art = getArt(t.image, 'extralarge') || getArt(t.image, 'large');
const artist = t.artist?.['#text'] || t.artist?.name || '—';
const album = t.album?.['#text'] || '';
const html = `
${art ? `
` : `
🎵
`}
${t.name}
${artist}${album ? ' · ' + album : ''}
`;
h.innerHTML = html; g.innerHTML = html;
// Update "Playing now" badge
const badgeText = document.getElementById('playing-badge-text');
if (badgeText && t) {
const artist = t.artist?.['#text'] || t.artist?.name || '';
const label = `${t.name}${artist ? ' · ' + artist : ''}`;
badgeText.textContent = label.length > 28 ? label.slice(0,28)+'…' : label;
const dot = document.querySelector('#playing-badge .dot');
if (dot) dot.style.background = 'var(--red)';
}
}
function renderNPEmpty() {
const html = `
🎵
Nothing playing right now
Play something with Last.fm scrobbling on.
`;
document.getElementById('np-wrap').innerHTML = html;
document.getElementById('gnp').innerHTML = html;
const badgeText = document.getElementById('playing-badge-text');
if (badgeText) badgeText.textContent = 'Nothing playing';
}
function renderRecent(tracks) {
const el = document.getElementById('recent-list'); if (!el || !tracks.length) return;
el.innerHTML = tracks.map(t => {
const art = getArt(t.image, 'small');
const artist = t.artist?.['#text'] || t.artist?.name || '';
const date = t.date?.['#text']?.split(',')[1]?.trim() || '';
return `
${art ? `
` : `
♪
`}
${date}
`;
}).join('');
}
// ── SEARCH ────────────────────────────────────────────────
async function doSearch(inputId, listId, isGuest = false) {
const q = document.getElementById(inputId).value.trim(); if (!q) return;
document.getElementById(listId).innerHTML = '
';
try {
const d = await lfm({ method: 'track.search', track: q, limit: 6 });
const tracks = d?.results?.trackmatches?.track || [];
if (!tracks.length) { document.getElementById(listId).innerHTML = '
No results
'; return; }
document.getElementById(listId).innerHTML = tracks.map(t => `
${t.image?.[1]?.['#text'] ? `
` : '
'}
${t.name}
${t.artist} · ${fmtN(t.listeners)} listeners
${isGuest ? 'Suggest' : '+ Add'}
`).join('');
} catch(e) { document.getElementById(listId).innerHTML = '
Search failed
'; }
}
async function addToQ(name, artist, art, isGuest = false) {
const r = await api('add_song', { name, artist, art });
if (r.ok && !r.duplicate) {
['hsr','gsr'].forEach(id => { const e = document.getElementById(id); if (e) e.innerHTML = ''; });
['hs','gs'].forEach(id => { const e = document.getElementById(id); if (e) e.value = ''; });
showToast(isGuest ? `📨 "${name}" suggested!` : `➕ "${name}" added to queue`);
pollState(); // refresh queue immediately
} else if (r.duplicate) {
showToast(`"${name}" is already in the queue!`);
} else {
showToast('⚠️ ' + r.error);
}
}
// ── VOTE QUEUE ────────────────────────────────────────────
function renderVQ() {
const cap = 10;
const count = voteQ.length;
// Update cap counter (host view)
const capLabel = document.getElementById('queue-cap-label');
if (capLabel) capLabel.textContent = `${count} / ${cap}`;
// Mark as Played button — host only, only when queue has songs
const mpBtn = document.getElementById('mark-played-btn');
if (mpBtn) mpBtn.classList.toggle('gone', persp !== 'host' || count === 0);
['vq', 'gvq'].forEach(id => {
const el = document.getElementById(id); if (!el) return;
if (!count) {
el.innerHTML = `
${persp === 'host' ? 'Queue empty — add up to 10 songs' : 'Queue empty — suggest a song below'}
`;
return;
}
const max = Math.max(...voteQ.map(t => t.votes || 0), 1);
const vid = getVoterId();
el.innerHTML = voteQ.map(t => {
const hasVoted = (t.voter_ids || []).includes(vid);
return `
${t.art ? `
` : `
♪
`}
${t.name}
${t.artist}${t.added_by ? ` · added by ${t.added_by} ` : ''}
${t.votes || 0} ${hasVoted ? '✓' : '▲'}
`;
}).join('');
if (count >= cap) {
el.innerHTML += `
Queue full (${cap}/${cap}) — wait for host to reset after next song
`;
}
});
se('g-votes-cast', voteQ.filter(t => (t.voter_ids||[]).includes(getVoterId())).length);
}
async function castVote(name) {
const r = await api('vote_song', { name });
if (r.ok) {
if (r.toggled === 'removed') showToast(`↩ Removed vote for "${name}"`);
else showToast(`🗳 Voted for "${name}"`);
pollState();
}
}
// ── PHOTOS — uploaded to server ──────────────────────────
// photos[] is populated from server via pollState → updateFromState
function loadPhotos() {} // no-op: photos come from server now
function savePhotos() {} // no-op: saved server-side
async function handleUpload(input) {
const files = Array.from(input.files);
if (!files.length) return;
showToast(`📤 Uploading ${files.length} photo${files.length>1?'s':''}…`);
const formData = new FormData();
formData.append('action', 'upload_photo');
formData.append('voter_id', getVoterId());
files.forEach(f => formData.append('photos[]', f));
try {
const res = await fetch(API, { method: 'POST', body: formData });
const data = await res.json();
if (data.ok) {
showToast(`📸 ${data.added?.length || files.length} photo${files.length>1?'s':''} uploaded!`);
pollState(); // refresh from server
} else {
showToast('⚠️ Upload failed: ' + (data.error || 'unknown error'));
}
} catch(e) {
showToast('⚠️ Upload error: ' + e.message);
}
input.value = '';
}
function renderPhotos(serverPhotos) {
// serverPhotos: [{url, time, ts}] from get_state, newest first
const list = serverPhotos || photos; // fallback to local if no server data yet
const n = list.length;
const label = n + ' photo' + (n !== 1 ? 's' : '');
['photo-count','g-photo-count'].forEach(id => se(id, label));
['recap-photos','g-photo-stat'].forEach(id => se(id, n));
const pct = Math.min(100, Math.round((n / 40) * 100));
const rb = document.getElementById('reel-bar'); if (rb) rb.style.width = pct + '%';
se('reel-pct', pct + '% captured');
['pg','gpg'].forEach(id => {
const el = document.getElementById(id); if (!el) return;
el.innerHTML = list.slice(0, 8).map((p, i) => {
const isVid = /\.(mp4|mov|webm|avi|m4v)$/i.test(p.url) || p.type === 'video';
const likes = (p.likes||[]).length;
const media = isVid
? `
`
: `
`;
return `
${media}
${likes > 0 ? `
👍${likes}
` : ''}
${p.uploaded_by || p.time}
`;
}).join('') + `
+Add
`;
// Force first-frame on any videos that are already loaded (cached)
el.querySelectorAll('video').forEach(v => {
if (v.readyState >= 1) v.currentTime = 0.1;
});
});
}
// ── LIGHTBOX ─────────────────────────────────────────────
let mediaList = [];
let lbIndex = 0;
let lbFilename = null; // track by filename to survive array reorders
function openLightbox(idx) {
if (!mediaList.length) { pollState(); return; }
lbIndex = Math.max(0, Math.min(idx, mediaList.length - 1));
lbFilename = mediaList[lbIndex]?.filename || mediaList[lbIndex]?.url?.split('/').pop() || null;
document.getElementById('lightbox').classList.remove('gone');
document.body.classList.add('noscroll');
renderLightbox();
}
function closeLightbox() {
document.getElementById('lightbox').classList.add('gone');
document.body.classList.remove('noscroll');
document.querySelectorAll('#lb-media-wrap video').forEach(v => v.pause());
}
function lightboxNav(dir) {
lbIndex = (lbIndex + dir + mediaList.length) % mediaList.length;
lbFilename = mediaList[lbIndex]?.filename || mediaList[lbIndex]?.url?.split('/').pop() || null;
renderLightbox();
}
function renderLightbox() {
const p = mediaList[lbIndex]; if (!p) return;
const vid = getVoterId();
const isVid = /\.(mp4|mov|webm|avi|m4v)$/i.test(p.url) || p.type === 'video';
const myLike = (p.likes || []).includes(vid);
const myDisl = (p.dislikes || []).includes(vid);
// Only replace media element if URL changed (prevents flash on reaction update)
const stage = document.getElementById('lb-media-wrap');
const existing = stage?.querySelector('img, video');
const curSrc = existing?.src || existing?.currentSrc || '';
const newSrc = new URL(p.url, location.href).href;
if (!existing || !curSrc.includes(p.url.split('/').pop())) {
document.querySelectorAll('#lb-media-wrap video').forEach(v => v.pause());
if (stage) stage.innerHTML = isVid
? `
`
: `
`;
}
// Update metadata
const name = p.uploaded_by || 'Guest';
const avEl = document.getElementById('lb-av');
if (avEl) avEl.textContent = name[0].toUpperCase();
se('lb-uploader', name);
se('lb-time', p.time || '');
se('lb-like-count', (p.likes || []).length);
se('lb-dislike-count', (p.dislikes || []).length);
se('lb-counter', `${lbIndex + 1} / ${mediaList.length}`);
// Reaction button states
const likeBtn = document.getElementById('lb-like-btn');
const dislBtn = document.getElementById('lb-dislike-btn');
if (likeBtn) { likeBtn.classList.toggle('liked', myLike); likeBtn.classList.toggle('disliked', false); }
if (dislBtn) { dislBtn.classList.toggle('disliked', myDisl); dislBtn.classList.toggle('liked', false); }
// Nav arrows: show only if >1 item
const showNav = mediaList.length > 1;
['lb-prev-btn','lb-next-btn'].forEach(id => {
const b = document.getElementById(id);
if (b) b.style.display = showNav ? '' : 'none';
});
}
async function reactMedia(reaction) {
const p = mediaList[lbIndex];
if (!p) { showToast('⚠️ No media selected'); return; }
const filename = p.filename || p.url?.split('/').pop();
if (!filename) { showToast('⚠️ Cannot identify this file — try re-uploading'); return; }
lbFilename = filename;
const r = await api('react_media', { filename, reaction });
if (!r.ok) { showToast('⚠️ ' + (r.error || 'Reaction failed')); return; }
// ── Optimistic update: mutate in-place for instant feedback ──
const vid = getVoterId();
const reactionKey = reaction === 'like' ? 'likes' : 'dislikes';
const oppositeKey = reaction === 'like' ? 'dislikes' : 'likes';
if (!p.likes) p.likes = [];
if (!p.dislikes) p.dislikes = [];
if (p[reactionKey].includes(vid)) {
p[reactionKey] = p[reactionKey].filter(v => v !== vid);
showToast(reaction === 'like' ? '👍 Removed' : '👎 Removed');
} else {
p[reactionKey].push(vid);
p[oppositeKey] = p[oppositeKey].filter(v => v !== vid);
showToast(reaction === 'like' ? '👍 Liked!' : '👎 Disliked!');
}
renderLightbox();
renderPhotos(mediaList);
// Block the regular poll from overwriting optimistic data
window._lastReactionAt = Date.now();
window._lastReactionFilename = filename;
// Targeted server refresh at T+2s — confirms the write landed
clearTimeout(window._reactionRefreshTimer);
window._reactionRefreshTimer = setTimeout(async () => {
const state = await api('get_state');
if (!state.ok || !state.photos) return;
const fresh = state.photos.find(ph =>
(ph.filename || ph.url?.split('/').pop()) === filename
);
if (!fresh) return;
// Patch only THIS photo in mediaList (don't replace the whole array)
const idx = mediaList.findIndex(ph =>
(ph.filename || ph.url?.split('/').pop()) === filename
);
if (idx > -1) {
mediaList[idx].likes = fresh.likes || [];
mediaList[idx].dislikes = fresh.dislikes || [];
mediaList[idx].like_count = fresh.like_count || 0;
mediaList[idx].dislike_count = fresh.dislike_count || 0;
if (lbFilename === filename) renderLightbox(); // only if still on same photo
renderPhotos(mediaList);
}
window._lastReactionAt = 0; // allow normal polling to resume
}, 2000);
}
// Keyboard nav for lightbox
document.addEventListener('keydown', e => {
const lb = document.getElementById('lightbox');
if (!lb || lb.classList.contains('gone')) return;
if (e.key === 'ArrowLeft') lightboxNav(-1);
if (e.key === 'ArrowRight') lightboxNav(1);
if (e.key === 'Escape') closeLightbox();
});
// Tap outside media to close
// Tap lightbox backdrop to close (but not media/buttons)
const _lb = document.getElementById('lightbox');
if (_lb) _lb.addEventListener('click', e => {
if (e.target === _lb) closeLightbox();
});
// ── RECAP MEDIA (most liked/disliked) ───────────────────
function updateRecapMedia(photos) {
if (!photos || !photos.length) return;
const byLikes = [...photos].sort((a,b)=>(b.like_count||0)-(a.like_count||0));
const byDislikes = [...photos].sort((a,b)=>(b.dislike_count||0)-(a.dislike_count||0));
const mostLiked = byLikes[0];
const mostDisliked = byDislikes[0];
['host','guest'].forEach(side => {
const card = document.getElementById(`media-recap-${side}`);
if (!card) return;
const hasAny = (mostLiked?.like_count || 0) > 0 || (mostDisliked?.dislike_count || 0) > 0;
card.classList.toggle('gone', !hasAny);
if (mostLiked && mostLiked.like_count > 0) {
const thumb = document.getElementById(`most-liked-thumb-${side}`);
const name = document.getElementById(`most-liked-name-${side}`);
const isVid = /\.(mp4|mov|webm|avi|m4v)$/i.test(mostLiked.url);
if (thumb) thumb.innerHTML = isVid
? `
`
: `
`;
if (name) name.textContent = `by ${mostLiked.uploaded_by} · 👍 ${mostLiked.like_count}`;
}
if (mostDisliked && mostDisliked.dislike_count > 0) {
const thumb = document.getElementById(`most-disliked-thumb-${side}`);
const name = document.getElementById(`most-disliked-name-${side}`);
const isVid = /\.(mp4|mov|webm|avi|m4v)$/i.test(mostDisliked.url);
if (thumb) thumb.innerHTML = isVid
? `
`
: `
`;
if (name) name.textContent = `by ${mostDisliked.uploaded_by} · 👎 ${mostDisliked.dislike_count}`;
}
});
}
// ── RECAP STATS ───────────────────────────────────────────
async function fetchRecapStats() {
if (!CFG.lfmUser || !CFG.lfmKey) return;
try {
const from = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000);
const d = await lfm({ method: 'user.getRecentTracks', user: CFG.lfmUser, from, limit: 200 });
const raw = d?.recenttracks?.track;
const tracks = Array.isArray(raw) ? raw : (raw ? [raw] : []);
const total = parseInt(d?.recenttracks?.['@attr']?.total || tracks.length);
se('scrobble-count', total);
const freq = {};
tracks.forEach(t => { const k = `${t.name}|||${t.artist?.['#text']||''}`; freq[k] = (freq[k]||0)+1; });
const top = Object.entries(freq).sort((a, b) => b[1] - a[1])[0];
if (top) {
const [tn, ta] = top[0].split('|||');
se('top-name', tn); se('top-artist', ta + ' (×' + top[1] + ')');
const card = document.getElementById('top-track-card'); if (card) card.style.display = 'block';
}
if (tracks.length) {
const first = tracks[tracks.length-1]?.date?.['#text']?.split(',')[1]?.trim() || 'tonight';
se('recap-sub', `${CFG.hostName}'s party · started ${first}`);
}
} catch(e) {}
}
// ── NOMINEES MODAL — pre-fill with existing ─────────────
let mvpNominees = []; // kept in sync from updateFromState
function openNomineesModal() {
document.getElementById('nominees-input').value = mvpNominees.join('\n');
openModal('mvp-setup-modal');
}
// ── CLEAR GROUP VOTE ──────────────────────────────────────
async function clearGroupVote() {
if (!confirm('Remove the active group vote?')) return;
const r = await api('clear_group_vote', { pin: CFG.hostPin });
if (r.ok) {
// Hide the vote cards
['guest-gvote','clear-gvote-btn'].forEach(id => {
const e = document.getElementById(id);
if (e) e.style.display = 'none';
});
showToast('🗑 Group vote removed');
} else { showToast('⚠️ ' + r.error); }
}
// ── MARK SONG AS PLAYED — reset queue ────────────────────
async function markPlayed() {
if (!confirm('Mark current song as played and reset the vote queue?')) return;
const r = await api('mark_played', { pin: CFG.hostPin });
if (r.ok) {
voteQ = [];
renderVQ();
showToast('✅ Queue reset — 10 new slots open!');
} else { showToast('⚠️ ' + r.error); }
}
// ── CLEAR MVP VOTES ──────────────────────────────────────
async function clearMvpVotes() {
if (!confirm('Reset all MVP votes? This cannot be undone.')) return;
const r = await api('clear_mvp_votes', { pin: CFG.hostPin });
if (r.ok) {
localStorage.removeItem('bash_my_mvp_vote');
showToast('🔄 MVP votes reset');
pollState();
} else { showToast('⚠️ ' + r.error); }
}
// ── CLEAR ANNOUNCEMENT ────────────────────────────────────
async function clearAnnouncement() {
const r = await api('clear_announcement', { pin: CFG.hostPin });
if (r.ok) {
document.getElementById('guest-announce')?.classList.add('gone');
const clearBtn = document.getElementById('clear-announce-btn');
if (clearBtn) clearBtn.style.display = 'none';
showToast('✅ Announcement removed');
} else { showToast('⚠️ ' + r.error); }
}
// ── SEED NEXT PARTY ───────────────────────────────────────
function openSeedModal() {
// Pre-fill if already set
const existing = JSON.parse(localStorage.getItem('bash_next_party_details') || 'null');
document.getElementById('seed-date-input').value = existing?.date || '';
document.getElementById('seed-note-input').value = existing?.note || '';
// Show who's already RSVPed for next party
const list = JSON.parse(localStorage.getItem('bash_next_party_rsvps') || '[]');
const preview = document.getElementById('seed-rsvp-preview');
if (list.length) {
preview.innerHTML = `
${list.length} people are in: ${list.map(r => r.name).join(', ')}`;
} else {
preview.textContent = 'No one has RSVPed for the next party yet.';
}
openModal('seed-modal');
}
async function saveNextPartyDetails() {
const date = document.getElementById('seed-date-input').value.trim();
const note = document.getElementById('seed-note-input').value.trim();
if (!date) { showToast('⚠️ Set a date first'); return; }
const r = await api('set_next_party_details', { date, note, pin: CFG.hostPin });
if (r.ok) {
localStorage.setItem('bash_next_party_details', JSON.stringify({ date, note }));
closeModal('seed-modal');
showNextPartyDetails({ date, note });
showToast('🎉 Next party details saved — guests can see them now!');
} else { showToast('⚠️ ' + r.error); }
}
function showNextPartyDetails(details) {
if (!details) return;
// Host view
const hostCard = document.getElementById('seed-details-host');
if (hostCard) {
hostCard.classList.remove('gone');
const d = document.getElementById('seed-date-host'); if (d) d.textContent = details.date;
const n = document.getElementById('seed-note-host'); if (n) n.textContent = details.note || '';
// Count going
const list = JSON.parse(localStorage.getItem('bash_next_party_rsvps') || '[]');
const g = document.getElementById('seed-going-host');
if (g) g.textContent = list.length ? `${list.length} people confirmed` : '';
}
// Guest view
const guestCard = document.getElementById('seed-details-guest');
if (guestCard) {
guestCard.classList.remove('gone');
const d = document.getElementById('seed-date-guest'); if (d) d.textContent = details.date;
const n = document.getElementById('seed-note-guest'); if (n) n.textContent = details.note || '';
}
}
// ── NAV ───────────────────────────────────────────────────
function setPersp(p) {
persp = p;
document.getElementById('pb-host').classList.toggle('active', p === 'host');
document.getElementById('pb-guest').classList.toggle('active', p === 'guest');
// Guest name bar: always visible but colour changes per mode
updateNameChip();
// Mark as Played button: only in host DJ view
const mpBtn = document.getElementById('mark-played-btn');
if (mpBtn) mpBtn.classList.toggle('gone', p !== 'host');
updateVisible();
}
function setTab(tab) {
activeTab = tab;
['join','dj','capsule','recap'].forEach((t, i) => {
document.querySelectorAll('.nt')[i].classList.toggle('active', t === tab);
const b = document.getElementById('bn-' + t); if (b) b.classList.toggle('active', t === tab);
});
updateVisible();
if (tab === 'dj') fetchNP();
if (tab === 'recap') { fetchRecapStats(); pollState(); }
}
function updateVisible() {
document.querySelectorAll('.section').forEach(s => { s.classList.remove('visible'); s.style.display = 'none'; });
const el = document.getElementById(`${persp}-${activeTab}`);
if (el) { el.style.display = 'flex'; el.classList.add('visible'); }
}
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.classList.add('show');
clearTimeout(window._tt); window._tt = setTimeout(() => t.classList.remove('show'), 2600);
}
// ── INIT ──────────────────────────────────────────────────
loadCfg();
document.getElementById('setup-overlay').classList.add('gone');
const _myRsvp = localStorage.getItem('bash_my_rsvp');
const _hostMode = localStorage.getItem('bash_host_mode');
if (_myRsvp || _hostMode) {
showApp();
if (_hostMode) {
isHostAuth = true;
setPersp('host');
updateHostUI();
}
} else {
document.getElementById('rsvp-host-name').textContent = CFG.hostName + "'s Party";
document.getElementById('rsvp-overlay').classList.remove('gone');
}
// Safeguard: ensure body scroll is never stuck
document.body.classList.remove('noscroll');
// Close modals on backdrop click
document.querySelectorAll('.sheet-bg,.modal-bg').forEach(el => {
el.addEventListener('click', e => { if (e.target === el) el.classList.add('gone'); });
});