class VoiceChannel { constructor(ws) { this.ws = ws; this.localStream = null; this.peers = {}; // userId -> RTCPeerConnection this.participants = {}; // userId -> username this.currentChannelId = null; } async join(channelId) { if (this.currentChannelId === channelId) return; if (this.currentChannelId) this.leave(); console.log('Joining voice channel:', channelId); this.currentChannelId = channelId; try { this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); this.ws.send(JSON.stringify({ type: 'voice_join', channel_id: channelId, user_id: window.currentUserId, username: window.currentUsername })); this.updateVoiceUI(); } catch (e) { console.error('Failed to get local stream:', e); alert('Could not access microphone.'); this.currentChannelId = null; } } leave() { if (!this.currentChannelId) return; this.ws.send(JSON.stringify({ type: 'voice_leave', channel_id: this.currentChannelId, user_id: window.currentUserId })); if (this.localStream) { this.localStream.getTracks().forEach(track => track.stop()); this.localStream = null; } Object.values(this.peers).forEach(pc => pc.close()); this.peers = {}; this.participants = {}; this.currentChannelId = null; this.updateVoiceUI(); } async handleSignaling(data) { const { type, from, to, offer, answer, candidate, channel_id, username } = data; if (channel_id != this.currentChannelId) return; if (to && to != window.currentUserId) return; switch (type) { case 'voice_join': if (from != window.currentUserId) { this.participants[from] = username || `User ${from}`; this.createPeerConnection(from, true); this.updateVoiceUI(); } break; case 'voice_offer': this.participants[from] = username || `User ${from}`; await this.handleOffer(from, offer); this.updateVoiceUI(); break; case 'voice_answer': await this.handleAnswer(from, answer); break; case 'voice_ice_candidate': await this.handleCandidate(from, candidate); break; case 'voice_leave': if (this.peers[from]) { this.peers[from].close(); delete this.peers[from]; } delete this.participants[from]; this.updateVoiceUI(); break; } } createPeerConnection(userId, isOfferor) { if (this.peers[userId]) return this.peers[userId]; const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }); this.peers[userId] = pc; this.localStream.getTracks().forEach(track => { pc.addTrack(track, this.localStream); }); pc.onicecandidate = (event) => { if (event.candidate) { this.ws.send(JSON.stringify({ type: 'voice_ice_candidate', to: userId, from: window.currentUserId, candidate: event.candidate, channel_id: this.currentChannelId })); } }; pc.ontrack = (event) => { const remoteAudio = new Audio(); remoteAudio.srcObject = event.streams[0]; remoteAudio.play(); }; if (isOfferor) { pc.createOffer().then(offer => { return pc.setLocalDescription(offer); }).then(() => { this.ws.send(JSON.stringify({ type: 'voice_offer', to: userId, from: window.currentUserId, username: window.currentUsername, offer: pc.localDescription, channel_id: this.currentChannelId })); }); } return pc; } async handleOffer(from, offer) { const pc = this.createPeerConnection(from, false); await pc.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); this.ws.send(JSON.stringify({ type: 'voice_answer', to: from, from: window.currentUserId, answer: pc.localDescription, channel_id: this.currentChannelId })); } async handleAnswer(from, answer) { const pc = this.peers[from]; if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer)); } async handleCandidate(from, candidate) { const pc = this.peers[from]; if (pc) await pc.addIceCandidate(new RTCIceCandidate(candidate)); } updateVoiceUI() { document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = ''); if (this.currentChannelId) { const channelEl = document.querySelector(`.voice-item[data-channel-id="${this.currentChannelId}"]`); if (channelEl) { let listEl = channelEl.querySelector('.voice-users-list'); if (!listEl) { listEl = document.createElement('div'); listEl.className = 'voice-users-list ms-3'; channelEl.appendChild(listEl); } // Me this.addVoiceUserToUI(listEl, window.currentUserId, window.currentUsername); // Others Object.entries(this.participants).forEach(([uid, name]) => { this.addVoiceUserToUI(listEl, uid, name); }); } } } addVoiceUserToUI(container, userId, username) { const userEl = document.createElement('div'); userEl.className = 'voice-user small text-muted d-flex align-items-center mb-1'; userEl.innerHTML = `
${username} `; container.appendChild(userEl); } }