class VoiceChannel { constructor(ws) { this.ws = ws; this.localStream = null; this.screenStream = null; this.peers = {}; // userId -> RTCPeerConnection this.participants = {}; // userId -> username this.currentChannelId = null; this.isScreenSharing = false; } 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; } } async toggleScreenShare() { if (!this.currentChannelId) return; if (this.isScreenSharing) { this.stopScreenShare(); } else { try { this.screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true }); this.isScreenSharing = true; const videoTrack = this.screenStream.getVideoTracks()[0]; videoTrack.onended = () => this.stopScreenShare(); // Replace or add track to all peers Object.values(this.peers).forEach(pc => { pc.addTrack(videoTrack, this.screenStream); // Renegotiate this.renegotiate(pc); }); this.updateVoiceUI(); this.showLocalVideo(); } catch (e) { console.error('Failed to share screen:', e); } } } stopScreenShare() { if (this.screenStream) { this.screenStream.getTracks().forEach(track => track.stop()); this.screenStream = null; } this.isScreenSharing = false; // Remove video track from all peers Object.entries(this.peers).forEach(([userId, pc]) => { const senders = pc.getSenders(); const videoSender = senders.find(s => s.track && s.track.kind === 'video'); if (videoSender) { pc.removeTrack(videoSender); this.renegotiate(pc); } }); this.updateVoiceUI(); const localVideo = document.getElementById('local-video-container'); if (localVideo) localVideo.innerHTML = ''; } renegotiate(pc) { // Find which user this PC belongs to const userId = Object.keys(this.peers).find(key => this.peers[key] === pc); if (!userId) return; 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 })); }); } leave() { if (!this.currentChannelId) return; this.stopScreenShare(); 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(); const remoteVideo = document.getElementById(`remote-video-${from}`); if (remoteVideo) remoteVideo.remove(); 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; if (this.localStream) { this.localStream.getTracks().forEach(track => { pc.addTrack(track, this.localStream); }); } if (this.screenStream) { this.screenStream.getTracks().forEach(track => { pc.addTrack(track, this.screenStream); }); } 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) => { if (event.track.kind === 'audio') { const remoteAudio = new Audio(); remoteAudio.srcObject = event.streams[0]; remoteAudio.play(); } else if (event.track.kind === 'video') { this.handleRemoteVideo(userId, event.streams[0]); } }; 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)); } handleRemoteVideo(userId, stream) { let container = document.getElementById('video-grid'); if (!container) { container = document.createElement('div'); container.id = 'video-grid'; container.className = 'video-grid'; const chatContainer = document.querySelector('.chat-container'); if (chatContainer) { chatContainer.insertBefore(container, document.getElementById('messages-list')); } else { document.body.appendChild(container); } } let video = document.getElementById(`remote-video-${userId}`); if (!video) { video = document.createElement('video'); video.id = `remote-video-${userId}`; video.autoplay = true; video.playsinline = true; container.appendChild(video); } video.srcObject = stream; } showLocalVideo() { let container = document.getElementById('video-grid'); if (!container) { container = document.createElement('div'); container.id = 'video-grid'; container.className = 'video-grid'; const chatContainer = document.querySelector('.chat-container'); if (chatContainer) { chatContainer.insertBefore(container, document.getElementById('messages-list')); } else { document.body.appendChild(container); } } let video = document.getElementById('local-video'); if (!video) { video = document.createElement('video'); video.id = 'local-video'; video.autoplay = true; video.playsinline = true; video.muted = true; container.appendChild(video); } video.srcObject = this.screenStream; } 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); }); } // Show voice controls if not already there if (!document.querySelector('.voice-controls')) { const controls = document.createElement('div'); controls.className = 'voice-controls p-2 d-flex justify-content-between align-items-center border-top bg-dark'; controls.innerHTML = `