style: auto-fix linting issues
This commit is contained in:
parent
2a6c763176
commit
b00cb086f4
3 changed files with 360 additions and 142 deletions
82
index.html
82
index.html
|
|
@ -1832,16 +1832,24 @@
|
|||
<div style="text-align: center; padding: 4rem 2rem; max-width: 600px; margin: 0 auto">
|
||||
<h2 class="section-title">Listening Parties</h2>
|
||||
<p style="color: var(--muted-foreground); margin-bottom: 2rem">
|
||||
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.
|
||||
</p>
|
||||
<div id="parties-auth-required" style="display: none">
|
||||
<p style="margin-bottom: 1rem">You need an account to host a listening party.</p>
|
||||
<button id="parties-login-btn" class="btn-primary">Sign In / Sign Up</button>
|
||||
</div>
|
||||
<div id="parties-host-controls" style="display: none">
|
||||
<input type="text" id="party-name-input" class="template-input" placeholder="Party Name" style="margin-bottom: 1rem">
|
||||
<button id="create-party-btn" class="btn-primary" style="width: 100%">Create Listening Party</button>
|
||||
<input
|
||||
type="text"
|
||||
id="party-name-input"
|
||||
class="template-input"
|
||||
placeholder="Party Name"
|
||||
style="margin-bottom: 1rem"
|
||||
/>
|
||||
<button id="create-party-btn" class="btn-primary" style="width: 100%">
|
||||
Create Listening Party
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1860,37 +1868,75 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<div class="party-content-layout" style="display: grid; grid-template-columns: 1fr 350px; gap: 2rem; margin-top: 2rem">
|
||||
<div
|
||||
class="party-content-layout"
|
||||
style="display: grid; grid-template-columns: 1fr 350px; gap: 2rem; margin-top: 2rem"
|
||||
>
|
||||
<div class="party-main-section">
|
||||
<section class="content-section">
|
||||
<h2 class="section-title">Currently Playing</h2>
|
||||
<div id="party-current-track-display">
|
||||
</div>
|
||||
<div id="party-current-track-display"></div>
|
||||
</section>
|
||||
|
||||
<section class="content-section">
|
||||
<h2 class="section-title">Participants (<span id="party-member-count">0</span>)</h2>
|
||||
<div id="party-members-list" class="card-grid compact">
|
||||
</div>
|
||||
<div id="party-members-list" class="card-grid compact"></div>
|
||||
</section>
|
||||
|
||||
<section class="content-section" id="party-requests-section">
|
||||
<h2 class="section-title">Song Requests</h2>
|
||||
<div id="party-requests-list" class="track-list">
|
||||
</div>
|
||||
<div id="party-requests-list" class="track-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="party-sidebar-section">
|
||||
<div class="party-chat-container" style="display: flex; flex-direction: column; height: 600px; background: var(--background-secondary); border-radius: var(--radius); border: 1px solid var(--border)">
|
||||
<div class="chat-header" style="padding: 1rem; border-bottom: 1px solid var(--border); font-weight: 600">
|
||||
<div
|
||||
class="party-chat-container"
|
||||
style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 600px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="chat-header"
|
||||
style="padding: 1rem; border-bottom: 1px solid var(--border); font-weight: 600"
|
||||
>
|
||||
Chat
|
||||
</div>
|
||||
<div id="party-chat-messages" style="flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem">
|
||||
</div>
|
||||
<div class="chat-input-container" style="padding: 1rem; border-top: 1px solid var(--border); display: flex; gap: 0.5rem">
|
||||
<input type="text" id="party-chat-input" class="template-input" placeholder="Say something..." style="flex: 1">
|
||||
<button id="party-chat-send-btn" class="btn-primary" style="padding: 0 1rem">Send</button>
|
||||
<div
|
||||
id="party-chat-messages"
|
||||
style="
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
class="chat-input-container"
|
||||
style="
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="party-chat-input"
|
||||
class="template-input"
|
||||
placeholder="Say something..."
|
||||
style="flex: 1"
|
||||
/>
|
||||
<button id="party-chat-send-btn" class="btn-primary" style="padding: 0 1rem">
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,20 +19,24 @@ class Modal {
|
|||
<h3 style="margin-bottom: 1rem; font-size: 1.5rem;">${title}</h3>
|
||||
<div class="modal-body" style="margin-bottom: 2rem; color: var(--muted-foreground); line-height: 1.5;">${content}</div>
|
||||
<div class="modal-actions" style="display: flex; flex-direction: column; gap: 0.75rem;">
|
||||
${actions.map((a, i) => `
|
||||
${actions
|
||||
.map(
|
||||
(a, i) => `
|
||||
<button class="btn-${a.type || 'secondary'} modal-action-btn" data-index="${i}" style="width: 100%; padding: 0.8rem; font-weight: 600;">${a.label}</button>
|
||||
`).join('')}
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 {
|
|||
<input type="text" id="guest-name-input" class="template-input" value="${defaultName}" placeholder="Your nickname" style="width: 100%; text-align: center;">
|
||||
`,
|
||||
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 {
|
|||
<div class="track-title" style="font-size: 1.8rem; font-weight: 700; margin-bottom: 0.5rem">${track.title}</div>
|
||||
<div class="track-artist" style="font-size: 1.2rem; color: var(--muted-foreground)">${getTrackArtists(track)}</div>
|
||||
</div>
|
||||
${!this.currentParty.is_playing ? `
|
||||
${
|
||||
!this.currentParty.is_playing
|
||||
? `
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; color: var(--primary); font-weight: 600; text-transform: uppercase; letter-spacing: 1px; font-size: 0.9rem">
|
||||
${SVG_PAUSE(24)} Paused
|
||||
</div>
|
||||
` : ''}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
} 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 => `<div class="member-item" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; background: var(--background-secondary); border-radius: var(--radius); border: 1px solid var(--border)"><img src="${m.avatar_url}" style="width: 40px; height: 40px; border-radius: 50%; background: var(--background-modifier-accent)"><div style="flex: 1; overflow: hidden"><div style="font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">${m.name}</div>${m.is_host ? '<div style="color: var(--primary); font-size: 0.7rem; font-weight: bold; text-transform: uppercase;">Host</div>' : '<div style="color: var(--muted-foreground); font-size: 0.7rem">Listening</div>'}</div></div>`).join('');
|
||||
list.innerHTML = this.members
|
||||
.map(
|
||||
(m) =>
|
||||
`<div class="member-item" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; background: var(--background-secondary); border-radius: var(--radius); border: 1px solid var(--border)"><img src="${m.avatar_url}" style="width: 40px; height: 40px; border-radius: 50%; background: var(--background-modifier-accent)"><div style="flex: 1; overflow: hidden"><div style="font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">${m.name}</div>${m.is_host ? '<div style="color: var(--primary); font-size: 0.7rem; font-weight: bold; text-transform: uppercase;">Host</div>' : '<div style="color: var(--muted-foreground); font-size: 0.7rem">Listening</div>'}</div></div>`
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
renderRequests() {
|
||||
|
|
@ -351,13 +424,14 @@ export class ListeningPartyManager {
|
|||
list.innerHTML = `<div style="padding: 2rem; text-align: center; color: var(--muted-foreground); font-size: 0.9rem">No requests yet. Right-click a song to request!</div>`;
|
||||
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 `<div class="track-item" style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; border-bottom: 1px solid var(--border)">
|
||||
|
||||
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 `<div class="track-item" style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; border-bottom: 1px solid var(--border)">
|
||||
<img src="${coverUrl}" style="width: 48px; height: 48px; border-radius: 4px; object-fit: cover; flex-shrink: 0;">
|
||||
<div class="track-info" style="flex: 1; min-width: 0;">
|
||||
<div class="track-title" style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${r.track.title || 'Unknown Title'}</div>
|
||||
|
|
@ -365,20 +439,25 @@ export class ListeningPartyManager {
|
|||
</div>
|
||||
${this.isHost ? `<button class="btn-primary btn-sm add-request-btn" data-req-id="${r.id}" style="padding: 0.4rem 1rem; font-size: 0.8rem; flex-shrink: 0; white-space: nowrap;">Add to Queue</button>` : ''}
|
||||
</div>`;
|
||||
} 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 `<a href="${url}" target="_blank" class="chat-link">${url}</a><img src="${url}" style="max-width: 100%; border-radius: 8px; margin-top: 8px; display: block; cursor: pointer" onclick="window.open('${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 `<a href="${url}" target="_blank" class="chat-link">${url}</a><iframe style="width: 100%; aspect-ratio: 16/9; border-radius: 8px; margin-top: 8px; border: none" src="https://www.youtube.com/embed/${ytMatch[1]}" allowfullscreen></iframe>`;
|
||||
}
|
||||
|
|
@ -407,7 +488,7 @@ export class ListeningPartyManager {
|
|||
}
|
||||
return `<a href="${url}" target="_blank" class="chat-link" style="color: var(--primary); text-decoration: underline;">${url}</a>`;
|
||||
});
|
||||
|
||||
|
||||
div.innerHTML = `
|
||||
<div style="font-weight: 600; font-size: 0.75rem; color: var(--primary); margin-bottom: 2px">${escapeHtml(msg.sender_name)}</div>
|
||||
<div style="background: var(--background-modifier-accent); padding: 0.6rem 0.8rem; border-radius: 0.75rem; display: inline-block; max-width: 100%; word-break: break-word; font-size: 0.9rem; line-height: 1.4">
|
||||
|
|
@ -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 = `
|
||||
<div class="party-indicator-content">
|
||||
<span class="party-indicator-label">Listening Party</span>
|
||||
|
|
|
|||
10
styles.css
10
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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue