diff --git a/index.html b/index.html index 6cfff22..6deece3 100644 --- a/index.html +++ b/index.html @@ -1832,16 +1832,24 @@

Listening Parties

- Listen to music together with your friends in real-time. - Host controls the music, everyone enjoys it. + Listen to music together with your friends in real-time. Host controls the music, everyone + enjoys it.

@@ -1860,37 +1868,75 @@ -
+

Currently Playing

-
-
+

Participants (0)

-
-
+

Song Requests

-
-
+
-
-
+
+
Chat
-
-
-
- - +
+
+ +
diff --git a/js/listening-party.js b/js/listening-party.js index dceb92d..9cddb6c 100644 --- a/js/listening-party.js +++ b/js/listening-party.js @@ -19,20 +19,24 @@ class Modal {

${title}

`; document.body.appendChild(modal); - + const cleanup = (val) => { modal.remove(); resolve(val); }; - modal.querySelectorAll('.modal-action-btn').forEach(btn => { + modal.querySelectorAll('.modal-action-btn').forEach((btn) => { btn.onclick = () => { const action = actions[btn.dataset.index]; if (action.callback) { @@ -52,7 +56,7 @@ class Modal { return this.show({ title, content: message, - actions: [{ label: 'OK', type: 'primary' }] + actions: [{ label: 'OK', type: 'primary' }], }); } @@ -62,8 +66,8 @@ class Modal { content: message, actions: [ { label: confirmLabel, type: type }, - { label: 'Cancel', type: 'secondary', callback: () => false } - ] + { label: 'Cancel', type: 'secondary', callback: () => false }, + ], }); } } @@ -82,7 +86,7 @@ export class ListeningPartyManager { this.isJoining = false; this.isInternalSync = false; this.originalSafePlay = null; - + this.setupEventListeners(); } @@ -119,25 +123,27 @@ export class ListeningPartyManager { is_playing: player.currentTrack ? !player.activeElement.paused : false, playback_time: player.activeElement.currentTime || 0, playback_timestamp: Date.now(), - queue: player.queue?.map(t => syncManager._minifyItem('track', t)) || [] + queue: player.queue?.map((t) => syncManager._minifyItem('track', t)) || [], }; if (currentTrack) partyData.current_track = currentTrack; try { const party = await pb.collection('parties').create(partyData, { f_id: user.$id }); navigate(`/party/${party.id}`); - } catch (e) { console.error('Create error:', e); } + } catch (e) { + console.error('Create error:', e); + } } async joinParty(partyId) { if (this.currentParty?.id === partyId || this.isJoining) return; this.isJoining = true; - + try { const user = authManager.user; const f_id = user ? user.$id : 'guest'; const party = await pb.collection('parties').getOne(partyId, { expand: 'host', f_id }); - + const confirmed = await this.showJoinModal(user); if (!confirmed) { this.isJoining = false; @@ -148,14 +154,20 @@ export class ListeningPartyManager { this.currentParty = party; const pbUser = user ? await syncManager._getUserRecord(user.$id) : null; this.isHost = pbUser && pbUser.id === party.host; - - const profile = confirmed.profile || await this.getMemberProfile(pbUser); - const memberData = { party: partyId, name: profile.name, avatar_url: profile.avatar_url, is_host: !!this.isHost, last_seen: Date.now() }; + + const profile = confirmed.profile || (await this.getMemberProfile(pbUser)); + const memberData = { + party: partyId, + name: profile.name, + avatar_url: profile.avatar_url, + is_host: !!this.isHost, + last_seen: Date.now(), + }; if (pbUser?.id) memberData.user = pbUser.id; const member = await pb.collection('party_members').create(memberData, { f_id }); this.memberId = member.id; - + this.setupSubscriptions(partyId); this.startHeartbeat(); this.renderPartyUI(); @@ -169,12 +181,12 @@ export class ListeningPartyManager { this.syncWithHost(party); } } - } catch (error) { - console.error('Join error:', error); + } catch (error) { + console.error('Join error:', error); Modal.alert('Error', 'Failed to join the party. It may have ended.'); - navigate('/parties'); - } finally { - this.isJoining = false; + navigate('/parties'); + } finally { + this.isJoining = false; } } @@ -198,18 +210,21 @@ export class ListeningPartyManager { `, actions: [ - { - label: 'Join Party', + { + label: 'Join Party', type: 'primary', callback: (modal) => { const name = modal.querySelector('#guest-name-input').value.trim() || 'Guest'; - const profile = { name, avatar_url: `https://api.dicebear.com/9.x/identicon/svg?seed=${name}` }; + const profile = { + name, + avatar_url: `https://api.dicebear.com/9.x/identicon/svg?seed=${name}`, + }; localStorage.setItem('party_guest_profile', JSON.stringify(profile)); return { profile }; - } + }, }, - { label: 'Cancel', type: 'secondary', callback: () => false } - ] + { label: 'Cancel', type: 'secondary', callback: () => false }, + ], }).then(resolve); }); } @@ -227,57 +242,96 @@ export class ListeningPartyManager { async getMemberProfile(pbUser = null) { const user = authManager.user; if (user) { - const name = pbUser?.display_name || pbUser?.username || user.displayName || user.email?.split('@')[0] || 'Member'; - const avatar = pbUser?.avatar_url || user.photoURL || `https://api.dicebear.com/9.x/identicon/svg?seed=${name}`; + const name = + pbUser?.display_name || pbUser?.username || user.displayName || user.email?.split('@')[0] || 'Member'; + const avatar = + pbUser?.avatar_url || user.photoURL || `https://api.dicebear.com/9.x/identicon/svg?seed=${name}`; return { name, avatar_url: avatar }; } const cached = localStorage.getItem('party_guest_profile'); - return cached ? JSON.parse(cached) : { name: 'Guest', avatar_url: 'https://api.dicebear.com/9.x/identicon/svg?seed=Guest' }; + return cached + ? JSON.parse(cached) + : { name: 'Guest', avatar_url: 'https://api.dicebear.com/9.x/identicon/svg?seed=Guest' }; } setupSubscriptions(partyId) { - this.unsubscribeFunctions.forEach(unsub => unsub()); + this.unsubscribeFunctions.forEach((unsub) => unsub()); this.unsubscribeFunctions = []; const f_id = authManager.user ? authManager.user.$id : 'guest'; - pb.collection('parties').subscribe(partyId, (e) => { - if (e.action === 'update') { - this.currentParty = e.record; - if (!this.isHost) this.syncWithHost(e.record); - this.updatePartyHeader(); - } else if (e.action === 'delete') { - Modal.alert('Party Ended', 'The host has ended the listening party.'); - this.leaveParty(false); - } - }, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub)); + pb.collection('parties') + .subscribe( + partyId, + (e) => { + if (e.action === 'update') { + this.currentParty = e.record; + if (!this.isHost) this.syncWithHost(e.record); + this.updatePartyHeader(); + } else if (e.action === 'delete') { + Modal.alert('Party Ended', 'The host has ended the listening party.'); + this.leaveParty(false); + } + }, + { f_id } + ) + .then((unsub) => this.unsubscribeFunctions.push(unsub)); - pb.collection('party_members').subscribe('*', (e) => { - if (e.record.party === partyId) this.loadMembers(); - }, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub)); + pb.collection('party_members') + .subscribe( + '*', + (e) => { + if (e.record.party === partyId) this.loadMembers(); + }, + { f_id } + ) + .then((unsub) => this.unsubscribeFunctions.push(unsub)); - pb.collection('party_messages').subscribe('*', (e) => { - if (e.record.party === partyId && e.action === 'create') this.addChatMessage(e.record); - }, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub)); + pb.collection('party_messages') + .subscribe( + '*', + (e) => { + if (e.record.party === partyId && e.action === 'create') this.addChatMessage(e.record); + }, + { f_id } + ) + .then((unsub) => this.unsubscribeFunctions.push(unsub)); - pb.collection('party_requests').subscribe('*', (e) => { - if (e.record.party === partyId) this.loadRequests(); - }, { f_id }).then(unsub => this.unsubscribeFunctions.push(unsub)); + pb.collection('party_requests') + .subscribe( + '*', + (e) => { + if (e.record.party === partyId) this.loadRequests(); + }, + { f_id } + ) + .then((unsub) => this.unsubscribeFunctions.push(unsub)); } - async loadInitialData(partyId) { this.loadMembers(); this.loadMessages(); this.loadRequests(); } + async loadInitialData(partyId) { + this.loadMembers(); + this.loadMessages(); + this.loadRequests(); + } async loadMembers() { const f_id = authManager.user ? authManager.user.$id : 'guest'; - this.members = await pb.collection('party_members').getFullList({ filter: `party = "${this.currentParty.id}"`, sort: '-is_host,name', f_id }); + this.members = await pb + .collection('party_members') + .getFullList({ filter: `party = "${this.currentParty.id}"`, sort: '-is_host,name', f_id }); this.renderMembers(); } async loadMessages() { const f_id = authManager.user ? authManager.user.$id : 'guest'; - const res = await pb.collection('party_messages').getList(1, 50, { filter: `party = "${this.currentParty.id}"`, sort: '-created', f_id }); + const res = await pb + .collection('party_messages') + .getList(1, 50, { filter: `party = "${this.currentParty.id}"`, sort: '-created', f_id }); this.messages = res.items.reverse(); const container = document.getElementById('party-chat-messages'); - if (container) { container.innerHTML = ''; this.messages.forEach(m => this.addChatMessage(m)); } + if (container) { + container.innerHTML = ''; + this.messages.forEach((m) => this.addChatMessage(m)); + } } async loadRequests() { @@ -286,26 +340,36 @@ export class ListeningPartyManager { this.requests = await pb.collection('party_requests').getFullList({ filter: `party = "${this.currentParty.id}"`, sort: 'created', - f_id: f_id + f_id: f_id, }); this.renderRequests(); - } catch (e) { console.error('Failed to load requests:', e); } + } catch (e) { + console.error('Failed to load requests:', e); + } } renderPartyUI() { - this.updatePartyHeader(); this.renderMembers(); this.renderRequests(); this.showPartyIndicator(); - if (this.isHost) { this.unlockControls(); this.setupHostPlayerSync(); } - else { this.lockControls(); this.setupGuestPlayerInterferenceCheck(); } + this.updatePartyHeader(); + this.renderMembers(); + this.renderRequests(); + this.showPartyIndicator(); + if (this.isHost) { + this.unlockControls(); + this.setupHostPlayerSync(); + } else { + this.lockControls(); + this.setupGuestPlayerInterferenceCheck(); + } } updatePartyHeader() { const titleEl = document.getElementById('party-title'); const countEl = document.getElementById('party-member-count'); const metaEl = document.getElementById('party-meta'); - + if (titleEl) titleEl.textContent = this.currentParty.name; if (countEl) countEl.textContent = this.members.length; - + if (metaEl) { const host = this.currentParty.expand?.host; const hostName = host?.display_name || host?.username || 'Unknown'; @@ -325,11 +389,15 @@ export class ListeningPartyManager {
${track.title}
${getTrackArtists(track)}
- ${!this.currentParty.is_playing ? ` + ${ + !this.currentParty.is_playing + ? `
${SVG_PAUSE(24)} Paused
- ` : ''} + ` + : '' + }
`; } else { @@ -341,7 +409,12 @@ export class ListeningPartyManager { renderMembers() { const list = document.getElementById('party-members-list'); if (!list) return; - list.innerHTML = this.members.map(m => `
${m.name}
${m.is_host ? '
Host
' : '
Listening
'}
`).join(''); + list.innerHTML = this.members + .map( + (m) => + `
${m.name}
${m.is_host ? '
Host
' : '
Listening
'}
` + ) + .join(''); } renderRequests() { @@ -351,13 +424,14 @@ export class ListeningPartyManager { list.innerHTML = `
No requests yet. Right-click a song to request!
`; return; } - - list.innerHTML = this.requests.map(r => { - try { - const api = Player.instance.api; - const artists = getTrackArtists(r.track); - const coverUrl = api.getCoverUrl(r.track.artwork || r.track.cover || r.track.album?.cover); - return `
+ + list.innerHTML = this.requests + .map((r) => { + try { + const api = Player.instance.api; + const artists = getTrackArtists(r.track); + const coverUrl = api.getCoverUrl(r.track.artwork || r.track.cover || r.track.album?.cover); + return `
${r.track.title || 'Unknown Title'}
@@ -365,20 +439,25 @@ export class ListeningPartyManager {
${this.isHost ? `` : ''}
`; - } catch (e) { return ''; } - }).join(''); + } catch (e) { + return ''; + } + }) + .join(''); if (this.isHost) { const f_id = authManager.user ? authManager.user.$id : 'guest'; - list.querySelectorAll('.add-request-btn').forEach(btn => btn.addEventListener('click', async (e) => { - const reqId = e.currentTarget.dataset.reqId; - const req = this.requests.find(r => r.id === reqId); - if (req) { - Player.instance.addToQueue(req.track); - showNotification(`Added "${req.track.title}" to queue`); - await pb.collection('party_requests').delete(req.id, { f_id }); - } - })); + list.querySelectorAll('.add-request-btn').forEach((btn) => + btn.addEventListener('click', async (e) => { + const reqId = e.currentTarget.dataset.reqId; + const req = this.requests.find((r) => r.id === reqId); + if (req) { + Player.instance.addToQueue(req.track); + showNotification(`Added "${req.track.title}" to queue`); + await pb.collection('party_requests').delete(req.id, { f_id }); + } + }) + ); } } @@ -387,15 +466,17 @@ export class ListeningPartyManager { if (!container) return; const div = document.createElement('div'); div.className = 'chat-msg'; - + const urlRegex = /(https?:\/\/[^\s]+)/g; let content = escapeHtml(msg.content); - + content = content.replace(urlRegex, (url) => { if (url.match(/\.(jpeg|jpg|gif|png|webp|svg)(\?.*)?$/i)) { return `${url}`; } - const ytMatch = url.match(/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/i); + const ytMatch = url.match( + /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/i + ); if (ytMatch) { return `${url}`; } @@ -407,7 +488,7 @@ export class ListeningPartyManager { } return `${url}`; }); - + div.innerHTML = `
${escapeHtml(msg.sender_name)}
@@ -421,10 +502,15 @@ export class ListeningPartyManager { async sendChatMessage() { const input = document.getElementById('party-chat-input'); if (!input || !input.value.trim()) return; - const content = input.value.trim(); input.value = ''; + const content = input.value.trim(); + input.value = ''; const profile = await this.getMemberProfile(); const f_id = authManager.user ? authManager.user.$id : 'guest'; - try { await pb.collection('party_messages').create({ party: this.currentParty.id, sender_name: profile.name, content }, { f_id }); } catch (e) {} + try { + await pb + .collection('party_messages') + .create({ party: this.currentParty.id, sender_name: profile.name, content }, { f_id }); + } catch (e) {} } async requestSong(track) { @@ -433,13 +519,18 @@ export class ListeningPartyManager { const f_id = authManager.user ? authManager.user.$id : 'guest'; try { const minifiedTrack = syncManager._minifyItem('track', track); - await pb.collection('party_requests').create({ - party: this.currentParty.id, - track: minifiedTrack, - requested_by: profile.name - }, { f_id }); + await pb.collection('party_requests').create( + { + party: this.currentParty.id, + track: minifiedTrack, + requested_by: profile.name, + }, + { f_id } + ); showNotification(`Requested "${track.title}"`); - } catch (e) { console.error('Request error:', e); } + } catch (e) { + console.error('Request error:', e); + } } async syncWithHost(party) { @@ -448,18 +539,23 @@ export class ListeningPartyManager { try { const player = Player.instance; const el = player.activeElement; - if (!party.current_track) { if (player.currentTrack) el.pause(); return; } - + if (!party.current_track) { + if (player.currentTrack) el.pause(); + return; + } + const currentId = String(player.currentTrack?.id || ''); const targetId = String(party.current_track.id || ''); if (currentId !== targetId) { const cleanedTrack = { ...party.current_track }; - delete cleanedTrack.audioUrl; delete cleanedTrack.streamUrl; delete cleanedTrack.remoteUrl; + delete cleanedTrack.audioUrl; + delete cleanedTrack.streamUrl; + delete cleanedTrack.remoteUrl; player.setQueue([cleanedTrack], 0); await player.playTrackFromQueue(party.playback_time); if (!party.is_playing) el.pause(); - return; + return; } if (party.is_playing) { @@ -473,17 +569,57 @@ export class ListeningPartyManager { if (!el.paused) el.pause(); if (Math.abs(el.currentTime - party.playback_time) > 0.5) el.currentTime = party.playback_time; } - } catch (e) { console.error('Sync error:', e); } finally { this.isInternalSync = false; } + } catch (e) { + console.error('Sync error:', e); + } finally { + this.isInternalSync = false; + } } lockControls() { - const selectors = ['.play-pause-btn', '#next-btn', '#prev-btn', '#shuffle-btn', '#repeat-btn', '#progress-bar', '#fs-play-pause-btn', '#fs-next-btn', '#fs-prev-btn', '#fs-shuffle-btn', '#fs-repeat-btn', '#fs-progress-bar']; - selectors.forEach(s => document.querySelectorAll(s).forEach(el => { el.style.opacity = '0.5'; el.style.pointerEvents = 'none'; })); + const selectors = [ + '.play-pause-btn', + '#next-btn', + '#prev-btn', + '#shuffle-btn', + '#repeat-btn', + '#progress-bar', + '#fs-play-pause-btn', + '#fs-next-btn', + '#fs-prev-btn', + '#fs-shuffle-btn', + '#fs-repeat-btn', + '#fs-progress-bar', + ]; + selectors.forEach((s) => + document.querySelectorAll(s).forEach((el) => { + el.style.opacity = '0.5'; + el.style.pointerEvents = 'none'; + }) + ); } unlockControls() { - const selectors = ['.play-pause-btn', '#next-btn', '#prev-btn', '#shuffle-btn', '#repeat-btn', '#progress-bar', '#fs-play-pause-btn', '#fs-next-btn', '#fs-prev-btn', '#fs-shuffle-btn', '#fs-repeat-btn', '#fs-progress-bar']; - selectors.forEach(s => document.querySelectorAll(s).forEach(el => { el.style.opacity = '1'; el.style.pointerEvents = 'auto'; })); + const selectors = [ + '.play-pause-btn', + '#next-btn', + '#prev-btn', + '#shuffle-btn', + '#repeat-btn', + '#progress-bar', + '#fs-play-pause-btn', + '#fs-next-btn', + '#fs-prev-btn', + '#fs-shuffle-btn', + '#fs-repeat-btn', + '#fs-progress-bar', + ]; + selectors.forEach((s) => + document.querySelectorAll(s).forEach((el) => { + el.style.opacity = '1'; + el.style.pointerEvents = 'auto'; + }) + ); } setupHostPlayerSync() { @@ -493,16 +629,20 @@ export class ListeningPartyManager { const el = player.activeElement; const sharedTrack = player.currentTrack ? syncManager._minifyItem('track', player.currentTrack) : null; try { - await pb.collection('parties').update(this.currentParty.id, { - current_track: sharedTrack, - is_playing: !el.paused, - playback_time: el.currentTime, - playback_timestamp: Date.now(), - queue: player.queue?.map(t => syncManager._minifyItem('track', t)) || [] - }, { f_id: authManager.user?.$id }); + await pb.collection('parties').update( + this.currentParty.id, + { + current_track: sharedTrack, + is_playing: !el.paused, + playback_time: el.currentTime, + playback_timestamp: Date.now(), + queue: player.queue?.map((t) => syncManager._minifyItem('track', t)) || [], + }, + { f_id: authManager.user?.$id } + ); } catch (e) {} }; - ['play', 'pause', 'seeked'].forEach(ev => { + ['play', 'pause', 'seeked'].forEach((ev) => { player.audio.addEventListener(ev, updateParty); if (player.video) player.video.addEventListener(ev, updateParty); }); @@ -520,7 +660,12 @@ export class ListeningPartyManager { const originalPlayTrackFromQueue = player.playTrackFromQueue.bind(player); player.playTrackFromQueue = async (...args) => { if (this.currentParty && !this.isHost && !this.isInternalSync) { - const leave = await Modal.confirm('Leave Party?', 'Playing a song will cause you to leave the listening party. Are you sure?', 'Leave and Play', 'danger'); + const leave = await Modal.confirm( + 'Leave Party?', + 'Playing a song will cause you to leave the listening party. Are you sure?', + 'Leave and Play', + 'danger' + ); if (!leave) return; this.leaveParty(); } @@ -531,39 +676,60 @@ export class ListeningPartyManager { startHeartbeat() { this.heartbeatInterval = setInterval(async () => { if (!this.memberId) return; - try { await pb.collection('party_members').update(this.memberId, { last_seen: Date.now() }, { f_id: authManager.user?.$id || 'guest' }); } catch (e) {} + try { + await pb + .collection('party_members') + .update(this.memberId, { last_seen: Date.now() }, { f_id: authManager.user?.$id || 'guest' }); + } catch (e) {} }, 30000); } async leaveParty(shouldCleanup = true) { const f_id = authManager.user?.$id || 'guest'; if (this.isHost && shouldCleanup) { - const end = await Modal.confirm('End Party?', 'Leaving will end the party for everyone. Are you sure?', 'End Party', 'danger'); + const end = await Modal.confirm( + 'End Party?', + 'Leaving will end the party for everyone. Are you sure?', + 'End Party', + 'danger' + ); if (!end) return; try { const cleanup = async (coll) => { - const items = await pb.collection(coll).getFullList({ filter: `party = "${this.currentParty.id}"`, f_id }); + const items = await pb + .collection(coll) + .getFullList({ filter: `party = "${this.currentParty.id}"`, f_id }); for (const i of items) await pb.collection(coll).delete(i.id, { f_id }); }; - await cleanup('party_members'); await cleanup('party_messages'); await cleanup('party_requests'); + await cleanup('party_members'); + await cleanup('party_messages'); + await cleanup('party_requests'); await pb.collection('parties').delete(this.currentParty.id, { f_id }); } catch (e) {} } else if (this.memberId) { - try { await pb.collection('party_members').delete(this.memberId, { f_id }); } catch (e) {} + try { + await pb.collection('party_members').delete(this.memberId, { f_id }); + } catch (e) {} } this.restorePlayerMethods(); this.unlockControls(); - this.unsubscribeFunctions.forEach(unsub => unsub()); + this.unsubscribeFunctions.forEach((unsub) => unsub()); this.unsubscribeFunctions = []; - clearInterval(this.syncInterval); clearInterval(this.heartbeatInterval); - this.currentParty = null; this.isHost = false; this.memberId = null; + clearInterval(this.syncInterval); + clearInterval(this.heartbeatInterval); + this.currentParty = null; + this.isHost = false; + this.memberId = null; document.getElementById('party-indicator')?.remove(); navigate('/parties'); } restorePlayerMethods() { const player = Player.instance; - if (this.originalSafePlay) { player.safePlay = this.originalSafePlay; this.originalSafePlay = null; } + if (this.originalSafePlay) { + player.safePlay = this.originalSafePlay; + this.originalSafePlay = null; + } } copyInviteLink() { @@ -580,7 +746,7 @@ export class ListeningPartyManager { document.body.appendChild(indicator); indicator.onclick = () => navigate(`/party/${this.currentParty.id}`); } - + indicator.innerHTML = `
Listening Party diff --git a/styles.css b/styles.css index 71ecf74..8399b70 100644 --- a/styles.css +++ b/styles.css @@ -9180,8 +9180,14 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { } @keyframes fadeIn { - from { opacity: 0; transform: translateY(5px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: translateY(0); + } } .party-status-badge {