feat(social): Listening Parties
This commit is contained in:
parent
edc0b5d1bd
commit
6ad728d106
8 changed files with 845 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
77
index.html
77
index.html
|
|
@ -58,6 +58,7 @@
|
|||
</li>
|
||||
<li data-action="toggle-pin" data-type-filter="album,artist,playlist,user-playlist">Pin</li>
|
||||
<li data-action="add-to-playlist" data-type-filter="track,video">Add to playlist</li>
|
||||
<li data-action="request-song" data-type-filter="track,video">Request song</li>
|
||||
<li data-action="go-to-artist" data-type-filter="track,album,video">Go to artist</li>
|
||||
<li data-action="go-to-album" data-type-filter="track,video">Go to album</li>
|
||||
<li data-action="copy-link">Copy link</li>
|
||||
|
|
@ -1600,6 +1601,12 @@
|
|||
<span>Discord</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" id="sidebar-nav-party">
|
||||
<a href="/parties" id="sidebar-party-btn">
|
||||
<use svg="!lucide/users.svg" size="24" />
|
||||
<span>Parties</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" id="sidebar-nav-githubbtn" style="display: none">
|
||||
<a href="https://github.com/monochrome-music/monochrome" target="_blank">
|
||||
<use svg="./images/github.svg" size="24" />
|
||||
|
|
@ -1821,6 +1828,76 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-parties" class="page">
|
||||
<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.
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-party-detail" class="page">
|
||||
<div class="party-container">
|
||||
<header class="detail-header">
|
||||
<div class="detail-header-info">
|
||||
<div class="type">Listening Party</div>
|
||||
<h1 class="title" id="party-title">Party Name</h1>
|
||||
<div class="meta" id="party-meta">Host: ...</div>
|
||||
<div class="detail-header-actions">
|
||||
<button id="leave-party-btn" class="btn-secondary danger">Leave Party</button>
|
||||
<button id="copy-party-link-btn" class="btn-secondary">Copy Invite Link</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-search" class="page">
|
||||
<h2 class="section-title" id="search-results-title">Search Results</h2>
|
||||
<div class="search-tabs">
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { authManager } from './accounts/auth.js';
|
|||
import { registerSW } from 'virtual:pwa-register';
|
||||
import { openEditProfile } from './profile.js';
|
||||
import { ThemeStore } from './themeStore.js';
|
||||
import { partyManager } from './listening-party.js';
|
||||
import './commandPalette.js';
|
||||
import { initTracker } from './tracker.js';
|
||||
import {
|
||||
|
|
|
|||
15
js/events.js
15
js/events.js
|
|
@ -55,6 +55,7 @@ import {
|
|||
trackEvent,
|
||||
} from './analytics.js';
|
||||
import { SVG_BIN, SVG_MUTE, SVG_PAUSE, SVG_PLAY, SVG_VOLUME, SVG_CHECKBOX, SVG_CHECKBOX_CHECKED } from './icons.js';
|
||||
import { partyManager } from './listening-party.js';
|
||||
|
||||
let currentTrackIdForWaveform = null;
|
||||
|
||||
|
|
@ -1155,6 +1156,15 @@ export async function handleTrackAction(
|
|||
return;
|
||||
}
|
||||
|
||||
if (action === 'request-song') {
|
||||
if (partyManager.currentParty) {
|
||||
await partyManager.requestSong(item);
|
||||
} else {
|
||||
showNotification('You are not in a listening party');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'start-radio' || action === 'start-infinite-radio') {
|
||||
let tracks = [];
|
||||
if (type === 'track') {
|
||||
|
|
@ -1970,6 +1980,11 @@ async function updateContextMenuLikeState(contextMenu, contextTrack) {
|
|||
} else {
|
||||
item.style.display = 'block';
|
||||
}
|
||||
if (item.dataset.action === 'request-song') {
|
||||
if (!partyManager.currentParty) {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Update labels for Like/Save
|
||||
if (item.dataset.action === 'toggle-like') {
|
||||
|
|
|
|||
594
js/listening-party.js
Normal file
594
js/listening-party.js
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
import { pb, syncManager } from './accounts/pocketbase.js';
|
||||
import { authManager } from './accounts/auth.js';
|
||||
import { Player } from './player.js';
|
||||
import { navigate } from './router.js';
|
||||
import { getTrackArtists, escapeHtml } from './utils.js';
|
||||
import { audioContextManager } from './audio-context.js';
|
||||
import { showNotification } from './downloads.js';
|
||||
import { SVG_PAUSE } from './icons.js';
|
||||
|
||||
class Modal {
|
||||
static async show({ title, content, actions = [] }) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active';
|
||||
modal.style.zIndex = '10000';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-content" style="max-width: 450px; text-align: center; padding: 2.5rem;">
|
||||
<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) => `
|
||||
<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('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const cleanup = (val) => {
|
||||
modal.remove();
|
||||
resolve(val);
|
||||
};
|
||||
|
||||
modal.querySelectorAll('.modal-action-btn').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const action = actions[btn.dataset.index];
|
||||
if (action.callback) {
|
||||
const result = action.callback(modal);
|
||||
if (result !== false) cleanup(result ?? true);
|
||||
} else {
|
||||
cleanup(true);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
modal.querySelector('.modal-overlay').onclick = () => cleanup(false);
|
||||
});
|
||||
}
|
||||
|
||||
static async alert(title, message) {
|
||||
return this.show({
|
||||
title,
|
||||
content: message,
|
||||
actions: [{ label: 'OK', type: 'primary' }]
|
||||
});
|
||||
}
|
||||
|
||||
static async confirm(title, message, confirmLabel = 'Confirm', type = 'primary') {
|
||||
return this.show({
|
||||
title,
|
||||
content: message,
|
||||
actions: [
|
||||
{ label: confirmLabel, type: type },
|
||||
{ label: 'Cancel', type: 'secondary', callback: () => false }
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ListeningPartyManager {
|
||||
constructor() {
|
||||
this.currentParty = null;
|
||||
this.isHost = false;
|
||||
this.memberId = null;
|
||||
this.members = [];
|
||||
this.messages = [];
|
||||
this.requests = [];
|
||||
this.unsubscribeFunctions = [];
|
||||
this.syncInterval = null;
|
||||
this.heartbeatInterval = null;
|
||||
this.isJoining = false;
|
||||
this.isInternalSync = false;
|
||||
this.originalSafePlay = null;
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
document.getElementById('create-party-btn')?.addEventListener('click', () => this.createParty());
|
||||
document.getElementById('leave-party-btn')?.addEventListener('click', () => this.leaveParty());
|
||||
document.getElementById('copy-party-link-btn')?.addEventListener('click', () => this.copyInviteLink());
|
||||
document.getElementById('party-chat-send-btn')?.addEventListener('click', () => this.sendChatMessage());
|
||||
document.getElementById('party-chat-input')?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') this.sendChatMessage();
|
||||
});
|
||||
}
|
||||
|
||||
async createParty() {
|
||||
const nameInput = document.getElementById('party-name-input');
|
||||
const user = authManager.user;
|
||||
if (!user) {
|
||||
Modal.alert('Login Required', 'You must be logged in to host a listening party.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pbUser = await syncManager._getUserRecord(user.$id);
|
||||
if (!pbUser) {
|
||||
Modal.alert('Sync Error', 'Failed to sync user data. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = nameInput.value.trim() || `${user.displayName || user.username || 'Member'}'s Party`;
|
||||
const player = Player.instance;
|
||||
const currentTrack = player.currentTrack ? syncManager._minifyItem('track', player.currentTrack) : null;
|
||||
const partyData = {
|
||||
name: name,
|
||||
host: pbUser.id,
|
||||
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)) || []
|
||||
};
|
||||
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); }
|
||||
}
|
||||
|
||||
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;
|
||||
navigate('/parties');
|
||||
return;
|
||||
}
|
||||
|
||||
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() };
|
||||
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();
|
||||
this.loadInitialData(partyId);
|
||||
|
||||
if (!this.isHost) {
|
||||
this.lockControls();
|
||||
this.setupGuestSyncInterception();
|
||||
if (party.current_track) {
|
||||
await audioContextManager.resume();
|
||||
this.syncWithHost(party);
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
async showJoinModal(user) {
|
||||
if (user) {
|
||||
const confirmed = await Modal.confirm(
|
||||
'Join Party',
|
||||
`You are about to join a listening party. Everyone in the party will see your profile. Are you ready to listen together?`,
|
||||
'Join Party'
|
||||
);
|
||||
return confirmed ? { profile: null } : false;
|
||||
} else {
|
||||
return new Promise((resolve) => {
|
||||
const cached = localStorage.getItem('party_guest_profile');
|
||||
const defaultName = cached ? JSON.parse(cached).name : '';
|
||||
|
||||
Modal.show({
|
||||
title: 'Join as Guest',
|
||||
content: `
|
||||
<p style="margin-bottom: 1rem;">Enter a nickname to join the party!</p>
|
||||
<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',
|
||||
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 = `
|
||||
<div class="track-item active" style="display: flex; flex-direction: column; align-items: center; text-align: center; gap: 1.5rem; padding: 2rem; background: var(--background-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
|
||||
<img src="${coverUrl}" class="track-artwork" style="width: 250px; height: 250px; border-radius: var(--radius); object-fit: cover; box-shadow: 0 10px 30px rgba(0,0,0,0.3)">
|
||||
<div class="track-info">
|
||||
<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 ? `
|
||||
<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 {
|
||||
display.innerHTML = `<div style="padding: 4rem 2rem; text-align: center; background: var(--background-secondary); border-radius: var(--radius); border: 1px dashed var(--border)"><div style="color: var(--muted-foreground); font-size: 1.2rem">Waiting for host to play music...</div></div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
renderRequests() {
|
||||
const list = document.getElementById('party-requests-list');
|
||||
if (!list) return;
|
||||
if (this.requests.length === 0) {
|
||||
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)">
|
||||
<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>
|
||||
<div class="track-artist" style="font-size: 0.8rem; color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${artists} • Requested By ${r.requested_by || 'Member'}</div>
|
||||
</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('');
|
||||
|
||||
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 });
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
addChatMessage(msg) {
|
||||
const container = document.getElementById('party-chat-messages');
|
||||
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);
|
||||
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>`;
|
||||
}
|
||||
if (url.match(/\.(mp4|webm|ogg)(\?.*)?$/i)) {
|
||||
return `<a href="${url}" target="_blank" class="chat-link">${url}</a><video controls style="max-width: 100%; border-radius: 8px; margin-top: 8px; display: block"><source src="${url}"></video>`;
|
||||
}
|
||||
if (url.includes('tenor.com/view/')) {
|
||||
return `<a href="${url}" target="_blank" class="chat-link">${url}</a><div class="tenor-embed" data-postid="${url.split('-').pop()}" data-share-method="host" data-aspect-ratio="1" data-width="100%"><script type="text/javascript" async src="https://tenor.com/embed.js"></script></div>`;
|
||||
}
|
||||
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">
|
||||
${content}
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
async sendChatMessage() {
|
||||
const input = document.getElementById('party-chat-input');
|
||||
if (!input || !input.value.trim()) return;
|
||||
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) {}
|
||||
}
|
||||
|
||||
async requestSong(track) {
|
||||
if (!this.currentParty) return;
|
||||
const profile = await this.getMemberProfile();
|
||||
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 });
|
||||
showNotification(`Requested "${track.title}"`);
|
||||
} catch (e) { console.error('Request error:', e); }
|
||||
}
|
||||
|
||||
async syncWithHost(party) {
|
||||
if (this.isInternalSync) return;
|
||||
this.isInternalSync = true;
|
||||
try {
|
||||
const player = Player.instance;
|
||||
const el = player.activeElement;
|
||||
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;
|
||||
player.setQueue([cleanedTrack], 0);
|
||||
await player.playTrackFromQueue(party.playback_time);
|
||||
if (!party.is_playing) el.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (party.is_playing) {
|
||||
if (el.paused) {
|
||||
const success = await player.safePlay(el);
|
||||
}
|
||||
const latency = (Date.now() - party.playback_timestamp) / 1000;
|
||||
const targetTime = party.is_playing ? party.playback_time + latency : party.playback_time;
|
||||
if (Math.abs(el.currentTime - targetTime) > 1.2) el.currentTime = targetTime;
|
||||
} else {
|
||||
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; }
|
||||
}
|
||||
|
||||
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'; }));
|
||||
}
|
||||
|
||||
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'; }));
|
||||
}
|
||||
|
||||
setupHostPlayerSync() {
|
||||
const player = Player.instance;
|
||||
const updateParty = async () => {
|
||||
if (!this.currentParty || !this.isHost || this.isInternalSync) return;
|
||||
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 });
|
||||
} catch (e) {}
|
||||
};
|
||||
['play', 'pause', 'seeked'].forEach(ev => {
|
||||
player.audio.addEventListener(ev, updateParty);
|
||||
if (player.video) player.video.addEventListener(ev, updateParty);
|
||||
});
|
||||
const originalPlayTrackFromQueue = player.playTrackFromQueue.bind(player);
|
||||
player.playTrackFromQueue = async (...args) => {
|
||||
const result = await originalPlayTrackFromQueue(...args);
|
||||
if (!this.isInternalSync) await updateParty();
|
||||
return result;
|
||||
};
|
||||
this.syncInterval = setInterval(updateParty, 2000);
|
||||
}
|
||||
|
||||
setupGuestPlayerInterferenceCheck() {
|
||||
const player = Player.instance;
|
||||
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');
|
||||
if (!leave) return;
|
||||
this.leaveParty();
|
||||
}
|
||||
return await originalPlayTrackFromQueue(...args);
|
||||
};
|
||||
}
|
||||
|
||||
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) {}
|
||||
}, 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');
|
||||
if (!end) return;
|
||||
try {
|
||||
const cleanup = async (coll) => {
|
||||
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 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) {}
|
||||
}
|
||||
this.restorePlayerMethods();
|
||||
this.unlockControls();
|
||||
this.unsubscribeFunctions.forEach(unsub => unsub());
|
||||
this.unsubscribeFunctions = [];
|
||||
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; }
|
||||
}
|
||||
|
||||
copyInviteLink() {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/party/${this.currentParty.id}`);
|
||||
showNotification('Invite link copied!');
|
||||
}
|
||||
|
||||
showPartyIndicator() {
|
||||
let indicator = document.getElementById('party-indicator');
|
||||
if (!indicator) {
|
||||
indicator = document.createElement('div');
|
||||
indicator.id = 'party-indicator';
|
||||
indicator.className = 'party-indicator-card';
|
||||
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>
|
||||
<div class="party-indicator-name">${this.currentParty.name}</div>
|
||||
</div>
|
||||
<div class="party-indicator-count">${this.members.length}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export const partyManager = new ListeningPartyManager();
|
||||
|
|
@ -40,6 +40,12 @@ export function createRouter(ui) {
|
|||
};
|
||||
|
||||
switch (page) {
|
||||
case 'parties':
|
||||
await ui.renderPartiesPage();
|
||||
break;
|
||||
case 'party':
|
||||
await ui.renderPartyDetailPage(param);
|
||||
break;
|
||||
case 'search':
|
||||
await ui.renderSearchPage(decodeURIComponent(param));
|
||||
break;
|
||||
|
|
|
|||
25
js/ui.js
25
js/ui.js
|
|
@ -29,7 +29,9 @@ import {
|
|||
} from './storage.js';
|
||||
import { db } from './db.js';
|
||||
import { getVibrantColorFromImage } from './vibrant-color.js';
|
||||
import { syncManager } from './accounts/pocketbase.js';
|
||||
import { pb, syncManager } from './accounts/pocketbase.js';
|
||||
import { authManager } from './accounts/auth.js';
|
||||
import { partyManager } from './listening-party.js';
|
||||
import { Visualizer } from './visualizer.js';
|
||||
import { navigate } from './router.js';
|
||||
import { sidePanelManager } from './side-panel.js';
|
||||
|
|
@ -1778,6 +1780,27 @@ export class UIRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
async renderPartiesPage() {
|
||||
this.showPage('parties');
|
||||
const authRequired = document.getElementById('parties-auth-required');
|
||||
const hostControls = document.getElementById('parties-host-controls');
|
||||
const loginBtn = document.getElementById('parties-login-btn');
|
||||
|
||||
if (authManager.user) {
|
||||
authRequired.style.display = 'none';
|
||||
hostControls.style.display = 'block';
|
||||
} else {
|
||||
authRequired.style.display = 'block';
|
||||
hostControls.style.display = 'none';
|
||||
loginBtn.onclick = () => navigate('/account');
|
||||
}
|
||||
}
|
||||
|
||||
async renderPartyDetailPage(id) {
|
||||
this.showPage('party-detail');
|
||||
await partyManager.joinParty(id);
|
||||
}
|
||||
|
||||
async renderLibraryPage() {
|
||||
this.showPage('library');
|
||||
|
||||
|
|
|
|||
127
styles.css
127
styles.css
|
|
@ -2706,6 +2706,19 @@ body.multi-select-mode .track-item:hover {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#page-party-detail .detail-header-actions .btn-primary,
|
||||
#page-party-detail .detail-header-actions .btn-secondary {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
#page-party-detail .detail-header-actions .btn-primary span,
|
||||
#page-party-detail .detail-header-actions .btn-secondary span {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.detail-header-actions .card-menu-btn,
|
||||
.detail-header-actions #album-menu-btn {
|
||||
position: static !important;
|
||||
|
|
@ -9074,3 +9087,117 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
|
|||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.party-container {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.party-content-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 350px;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.party-content-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.party-indicator-card {
|
||||
position: fixed;
|
||||
bottom: calc(var(--player-bar-height-desktop) + 1.5rem);
|
||||
right: 1.5rem;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-right: 4px solid var(--primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.6rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
z-index: 2000;
|
||||
box-shadow: var(--shadow-lg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.party-indicator-card:hover {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.party-indicator-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.party-indicator-label {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.party-indicator-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.party-indicator-count {
|
||||
background: var(--secondary);
|
||||
color: var(--primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.party-indicator-card {
|
||||
bottom: calc(var(--player-bar-height-mobile) + 1rem);
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
margin-bottom: 0.5rem;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.party-status-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.member-item:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue