The all-in-one house party companion

Last.fm — Live Music

Displays what you're scrobbling in real time. Get a free API key at last.fm/api/account/create.

Party Info

Shown on the guest join screen.

🔐 Host PIN

Guests can't access Host controls without this PIN. Keep it to yourself.

Claude AI (optional)

Generates an AI party chronicle using your real scrobbles. Get a key at console.anthropic.com.

All settings saved in your browser. Update anytime via the ⚙️ icon (host only).

You're invited to
the Party

Create your party profile

Your PIN lets you log back in
if you switch phones
Already joined? Login → I'm the host →
⚙️ Settings
Last.fm
Claude AI
Party
Data
Nothing playing
👤 You · logged in
🔗 Guest Entry — Scan to Join
QR Code
SCAN TO JOIN · NO APP NEEDED
No download Browser only Instant join
👥 RSVP Counts
0
Going 🎉
0
Maybe 🤔
0
Can't 😔
⚡ Host Controls

Loading Last.fm…

🕓 Recent Scrobbles
🗳 Vote Queue 0 / 10
Search below to add songs
🔍 Search & Add
📸 Party Capsule 0 photos
🎬 Auto Reel Progress
🎞
Highlight Reel — Ready at Midnight
0% captured
🌅
Morning After
Tonight's party
0
RSVPs
Scrobbles
0
Photos
⭐ MVP Results
Results will appear here at end of party
🎉 Next Party RSVPs
No next-party RSVPs yet
👍 Most Liked
👎 Most Disliked
Next Party Set 🎉
📢
🎮 Icebreaker
🗳 Group Vote
Your RSVP
Scan this QR to share the party link
QR Code
SHARE THE PARTY
Tonight you can
🎵Vote on next songs in the queue
📸Add photos to the party capsule
Vote for tonight's MVP
🌅Get the morning recap
🗳 Vote — What Plays Next?
No songs in queue yet
➕ Suggest a Song
📸 Party Capsule 0 photos
⭐ Vote for MVP
Host hasn't set nominees yet
Tomorrow morning you'll get
🎞Highlight reel from tonight
✍️AI-written party story
MVP reveal
🌅
You were there!
Tonight · Great vibes
0
Votes Cast
0
Photos
Your MVP
Next Party 🎉
👍 Most Liked
👎 Most Disliked
✅ You're on the list for next time!
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 = `
NOW SCROBBLING
${art ? `` : `
🎵
`}
${t.name}
${artist}${album ? ' · ' + album : ''}
● live
`; 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 ? `` : `
`}
${t.name}
${artist}
${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
`).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}` : ''}
`; }).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'); }); });