diff --git a/README.md b/README.md index de1f68f..5b7c245 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ - Account system for cross-device syncing - Customizable & Public Profiles +- Real-time Listening Parties for synced playback with friends - Last.fm and ListenBrainz integration for scrobbling - OAuth support (Google, Discord, GitHub, Spotify) - Unreleased music from [ArtistGrid](https://artistgrid.cx) diff --git a/index.html b/index.html index 9cf8929..34bd4aa 100644 --- a/index.html +++ b/index.html @@ -58,6 +58,7 @@
+ Listen to music together with your friends in real-time. + Host controls the music, everyone enjoys it. +
+You need an account to host a listening party.
+ +Enter a nickname to join the party!
+ + `, + actions: [ + { + 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}` }; + localStorage.setItem('party_guest_profile', JSON.stringify(profile)); + return { profile }; + } + }, + { label: 'Cancel', type: 'secondary', callback: () => false } + ] + }).then(resolve); + }); + } + } + + setupGuestSyncInterception() { + const player = Player.instance; + if (!this.originalSafePlay) this.originalSafePlay = player.safePlay.bind(player); + player.safePlay = async (el) => { + if (this.currentParty && !this.isHost && !this.currentParty.is_playing) return false; + return await this.originalSafePlay(el); + }; + } + + 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}`; + 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' }; + } + + setupSubscriptions(partyId) { + 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('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_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 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.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 }); + this.messages = res.items.reverse(); + const container = document.getElementById('party-chat-messages'); + if (container) { container.innerHTML = ''; this.messages.forEach(m => this.addChatMessage(m)); } + } + + async loadRequests() { + const f_id = authManager.user ? authManager.user.$id : 'guest'; + try { + this.requests = await pb.collection('party_requests').getFullList({ + filter: `party = "${this.currentParty.id}"`, + sort: 'created', + f_id: f_id + }); + this.renderRequests(); + } 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(); } + } + + 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'; + metaEl.textContent = `Host: ${hostName}`; + } + + const track = this.currentParty.current_track; + const display = document.getElementById('party-current-track-display'); + if (display) { + if (track) { + const api = Player.instance.api; + const coverUrl = api.getCoverUrl(track.artwork || track.cover || track.album?.cover); + display.innerHTML = ` +