console.log('voice.js loaded'); class VoiceChannel { constructor(ws, settings) { this.ws = ws; this.settings = settings || { mode: 'vox', pttKey: 'v', voxThreshold: 0.1 }; console.log('VoiceChannel constructor called with settings:', this.settings); this.localStream = null; this.analysisStream = null; this.peers = {}; // userId -> RTCPeerConnection this.participants = {}; // userId -> {name} this.currentChannelId = null; this.myPeerId = null; this.pollInterval = null; this.remoteAudios = {}; // userId -> Audio element this.pendingCandidates = {}; // userId -> array of candidates this.audioContext = null; this.analyser = null; this.microphone = null; this.scriptProcessor = null; this.delayNode = null; this.voxDestination = null; this.isTalking = false; this.pttPressed = false; this.voxActive = false; this.lastVoiceTime = 0; this.voxHoldTime = 500; // Track who is speaking to persist across UI refreshes this.speakingUsers = new Set(); this.setupPTTListeners(); window.addEventListener('beforeunload', () => this.leave()); } setupPTTListeners() { window.addEventListener('keydown', (e) => { // Ignore if in input field if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (this.settings.mode !== 'ptt') return; const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() || (e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) || (this.settings.pttKey === '0' && e.code === 'Numpad0'); if (isMatch) { if (!this.pttPressed) { console.log('PTT Key Pressed:', e.key, e.code, 'Expected:', this.settings.pttKey); this.pttPressed = true; this.updateMuteState(); } } }); window.addEventListener('keyup', (e) => { if (this.settings.mode !== 'ptt') return; const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() || (e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) || (this.settings.pttKey === '0' && e.code === 'Numpad0'); if (isMatch) { console.log('PTT Key Released:', e.key, e.code, 'Expected:', this.settings.pttKey); this.pttPressed = false; this.updateMuteState(); } }); } async join(channelId) { console.log('VoiceChannel.join process started for channel:', channelId); if (this.currentChannelId === channelId) { console.log('Already in this channel'); return; } if (this.currentChannelId) { console.log('Leaving previous channel:', this.currentChannelId); this.leave(); } this.currentChannelId = channelId; try { console.log('Requesting microphone access...'); this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); console.log('Microphone access granted'); // Always setup VOX logic for volume meter and detection this.setupVOX(); // Initial mute (on the buffered stream if it exists) this.setMute(true); // Join via PHP console.log('Calling API join...'); const url = `api_v1_voice.php?action=join&room=${channelId}&name=${encodeURIComponent(window.currentUsername || 'Unknown')}`; const resp = await fetch(url); const data = await resp.json(); console.log('API join response:', data); if (data.success) { this.myPeerId = data.peer_id; console.log('Joined room with peer_id:', this.myPeerId); // Start polling this.startPolling(); this.updateVoiceUI(); } else { console.error('API join failed:', data.error); } } catch (e) { console.error('Failed to join voice:', e); alert('Microphone access required for voice channels. Error: ' + e.message); this.currentChannelId = null; } } startPolling() { if (this.pollInterval) clearInterval(this.pollInterval); this.pollInterval = setInterval(() => this.poll(), 1000); this.poll(); // Initial poll } async poll() { if (!this.myPeerId || !this.currentChannelId) return; try { const resp = await fetch(`api_v1_voice.php?action=poll&room=${this.currentChannelId}&peer_id=${this.myPeerId}`); const data = await resp.json(); if (data.success) { // Update participants const oldPs = Object.keys(this.participants); this.participants = data.participants; const newPs = Object.keys(this.participants); // If new people joined, initiate offer if I'm the "older" one (prevent glare by comparing IDs) newPs.forEach(pid => { if (pid !== this.myPeerId && !this.peers[pid]) { // Only initiate if my ID is greater than theirs (lexicographical comparison) if (this.myPeerId > pid) { console.log('Initiating offer to new peer:', pid); this.createPeerConnection(pid, true); } else { console.log('Waiting for offer from peer:', pid); // Just create the PC without offering, it will handle the incoming offer this.createPeerConnection(pid, false); } } }); // Cleanup left peers oldPs.forEach(pid => { if (!this.participants[pid] && this.peers[pid]) { console.log('Peer left:', pid); try { this.peers[pid].close(); } catch(e) {} delete this.peers[pid]; if (this.remoteAudios[pid]) { try { this.remoteAudios[pid].pause(); this.remoteAudios[pid].srcObject = null; this.remoteAudios[pid].remove(); } catch(e) {} delete this.remoteAudios[pid]; } // Also cleanup by class just in case document.querySelectorAll(`.voice-remote-audio[data-peer-id="${pid}"]`).forEach(el => el.remove()); } }); // Handle incoming signals if (data.signals && data.signals.length > 0) { for (const sig of data.signals) { await this.handleSignaling(sig); } } this.updateVoiceUI(); } } catch (e) { console.error('Polling error:', e); } } async sendSignal(to, data) { if (!this.myPeerId || !this.currentChannelId) return; await fetch(`api_v1_voice.php?action=signal&room=${this.currentChannelId}&peer_id=${this.myPeerId}&to=${to}&data=${encodeURIComponent(JSON.stringify(data))}`); } async handleSignaling(sig) { const { from, data } = sig; console.log('Handling signaling from:', from, data.type); switch (data.type) { case 'offer': await this.handleOffer(from, data.offer); break; case 'answer': await this.handleAnswer(from, data.answer); break; case 'ice_candidate': await this.handleCandidate(from, data.candidate); break; case 'voice_speaking': this.updateSpeakingUI(data.user_id, data.speaking); break; } } createPeerConnection(userId, isOfferor) { if (this.peers[userId]) return this.peers[userId]; console.log('Creating PeerConnection for:', userId, 'as offeror:', isOfferor); const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ] }); this.peers[userId] = pc; this.pendingCandidates[userId] = []; pc.oniceconnectionstatechange = () => { console.log(`ICE Connection State with ${userId}: ${pc.iceConnectionState}`); }; if (this.localStream) { const streamToShare = (this.voxDestination && this.voxDestination.stream) ? this.voxDestination.stream : this.localStream; streamToShare.getTracks().forEach(track => { console.log(`Adding track ${track.kind} to peer ${userId}`); pc.addTrack(track, streamToShare); }); } pc.onicecandidate = (event) => { if (event.candidate) { this.sendSignal(userId, { type: 'ice_candidate', candidate: event.candidate }); } }; pc.ontrack = (event) => { console.log('Received remote track from:', userId, event); const stream = event.streams[0] || new MediaStream([event.track]); if (this.remoteAudios[userId]) { console.log('Updating existing remote audio for:', userId); this.remoteAudios[userId].srcObject = stream; } else { console.log('Creating new remote audio for:', userId); const remoteAudio = new Audio(); remoteAudio.classList.add('voice-remote-audio'); remoteAudio.dataset.peerId = userId; remoteAudio.autoplay = true; remoteAudio.playsInline = true; remoteAudio.srcObject = stream; document.body.appendChild(remoteAudio); this.remoteAudios[userId] = remoteAudio; } this.remoteAudios[userId].play().then(() => { console.log('Audio playing for peer:', userId); }).catch(e => { console.warn('Autoplay prevented or failed for:', userId, e); // Try again on any click const retry = () => { this.remoteAudios[userId].play(); window.removeEventListener('click', retry); }; window.addEventListener('click', retry); }); }; if (isOfferor) { pc.createOffer().then(offer => { return pc.setLocalDescription(offer); }).then(() => { this.sendSignal(userId, { type: 'offer', offer: pc.localDescription }); }); } return pc; } async handleOffer(from, offer) { console.log('Received offer from:', from); const pc = this.createPeerConnection(from, false); try { await pc.setRemoteDescription(new RTCSessionDescription(offer)); console.log('Remote description set for offer from:', from); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); console.log('Local description (answer) set for:', from); this.sendSignal(from, { type: 'answer', answer: pc.localDescription }); await this.processPendingCandidates(from); } catch (e) { console.error('Error handling offer from', from, e); } } async handleAnswer(from, answer) { console.log('Received answer from:', from); const pc = this.peers[from]; if (pc) { try { await pc.setRemoteDescription(new RTCSessionDescription(answer)); console.log('Remote description (answer) set for:', from); await this.processPendingCandidates(from); } catch (e) { console.error('Error handling answer from', from, e); } } else { console.warn('Received answer but no peer connection for:', from); } } async handleCandidate(from, candidate) { console.log('Received ICE candidate from:', from); const pc = this.peers[from]; if (pc && pc.remoteDescription) { try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); console.log('Added ICE candidate for:', from); } catch (e) { console.error('Error adding ice candidate from', from, e); } } else if (pc) { console.log('Queuing ICE candidate for:', from); this.pendingCandidates[from].push(candidate); } else { console.warn('Received ICE candidate but no peer connection for:', from); } } async processPendingCandidates(userId) { const pc = this.peers[userId]; const candidates = this.pendingCandidates[userId]; if (pc && pc.remoteDescription && candidates && candidates.length > 0) { console.log(`Processing ${candidates.length} pending candidates for ${userId}`); while (candidates.length > 0) { const cand = candidates.shift(); try { await pc.addIceCandidate(new RTCIceCandidate(cand)); } catch (e) { console.error('Error processing pending candidate', e); } } } } setupVOX() { if (!this.localStream) { console.warn('Cannot setup VOX: no localStream'); return; } console.log('Setting up VOX logic...'); try { if (!this.audioContext) { this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); } // Re-ensure context is running if (this.audioContext.state === 'suspended') { this.audioContext.resume().then(() => console.log('AudioContext resumed')); } // Cleanup old nodes if (this.scriptProcessor) { this.scriptProcessor.onaudioprocess = null; try { this.scriptProcessor.disconnect(); } catch(e) {} } if (this.microphone) { try { this.microphone.disconnect(); } catch(e) {} } this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 512; // Use a cloned stream for analysis so VOX works even when localStream is muted/disabled if (this.analysisStream) { this.analysisStream.getTracks().forEach(t => t.stop()); } this.analysisStream = this.localStream.clone(); this.analysisStream.getAudioTracks().forEach(t => t.enabled = true); // Ensure analysis stream is NOT muted this.microphone = this.audioContext.createMediaStreamSource(this.analysisStream); this.scriptProcessor = this.audioContext.createScriptProcessor(2048, 1, 1); // Setup Delay Buffer for VOX this.delayNode = this.audioContext.createDelay(1.0); this.delayNode.delayTime.value = 0.3; // 300ms buffer this.voxDestination = this.audioContext.createMediaStreamDestination(); this.microphone.connect(this.delayNode); this.delayNode.connect(this.voxDestination); this.microphone.connect(this.analyser); this.analyser.connect(this.scriptProcessor); // Avoid feedback: connect to a gain node with 0 volume then to destination const silence = this.audioContext.createGain(); silence.gain.value = 0; this.scriptProcessor.connect(silence); silence.connect(this.audioContext.destination); this.voxActive = false; this.currentVolume = 0; this.scriptProcessor.onaudioprocess = () => { const array = new Uint8Array(this.analyser.frequencyBinCount); this.analyser.getByteFrequencyData(array); let values = 0; for (let i = 0; i < array.length; i++) values += array[i]; const average = values / array.length; this.currentVolume = average / 255; if (this.settings.mode !== 'vox') { this.voxActive = false; return; } if (this.currentVolume > this.settings.voxThreshold) { this.lastVoiceTime = Date.now(); if (!this.voxActive) { this.voxActive = true; this.updateMuteState(); } } else { if (this.voxActive && Date.now() - this.lastVoiceTime > this.voxHoldTime) { this.voxActive = false; this.updateMuteState(); } } }; console.log('VOX logic setup complete'); } catch (e) { console.error('Failed to setup VOX:', e); } } getVolume() { return this.currentVolume || 0; } updateMuteState() { if (!this.currentChannelId || !this.localStream) return; let shouldTalk = (this.settings.mode === 'ptt') ? this.pttPressed : this.voxActive; console.log('updateMuteState: shouldTalk =', shouldTalk, 'mode =', this.settings.mode); if (this.isTalking !== shouldTalk) { this.isTalking = shouldTalk; this.setMute(!shouldTalk); this.updateSpeakingUI(window.currentUserId, shouldTalk); // Notify others const msg = { type: 'voice_speaking', channel_id: this.currentChannelId, user_id: window.currentUserId, speaking: shouldTalk }; if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(msg)); } else { Object.keys(this.peers).forEach(pid => { this.sendSignal(pid, msg); }); } } } setMute(mute) { // We mute the destination stream (delayed) instead of the localStream source // to ensure the delay buffer keeps filling with live audio. const streamToMute = (this.voxDestination && this.voxDestination.stream) ? this.voxDestination.stream : this.localStream; if (streamToMute) { console.log('Setting mute to:', mute, 'on stream:', streamToMute.id); streamToMute.getAudioTracks().forEach(track => { track.enabled = !mute; }); } } leave() { if (!this.currentChannelId) return; const roomToLeave = this.currentChannelId; const myIdToLeave = this.myPeerId; console.log('Leaving voice channel:', roomToLeave); // 1. Clear interval and set state to null immediately to stop any ongoing poll/logic if (this.pollInterval) clearInterval(this.pollInterval); this.pollInterval = null; this.currentChannelId = null; this.myPeerId = null; // 2. Notify server const leaveUrl = `api_v1_voice.php?action=leave&room=${roomToLeave}&peer_id=${myIdToLeave}`; if (navigator.sendBeacon) { navigator.sendBeacon(leaveUrl); } else { fetch(leaveUrl); } // 3. Stop all local audio tracks if (this.localStream) { console.log('Stopping localStream tracks'); this.localStream.getTracks().forEach(track => { track.enabled = false; track.stop(); }); this.localStream = null; } if (this.analysisStream) { this.analysisStream.getTracks().forEach(track => track.stop()); this.analysisStream = null; } // 4. Cleanup VOX/AudioContext nodes if (this.scriptProcessor) { try { this.scriptProcessor.onaudioprocess = null; this.scriptProcessor.disconnect(); } catch(e) {} this.scriptProcessor = null; } if (this.microphone) { try { this.microphone.disconnect(); } catch(e) {} this.microphone = null; } if (this.delayNode) { try { this.delayNode.disconnect(); } catch(e) {} this.delayNode = null; } this.voxDestination = null; if (this.audioContext && this.audioContext.state !== 'closed') { try { this.audioContext.suspend(); } catch(e) {} } // 5. Close all peer connections console.log('Closing all peer connections:', Object.keys(this.peers)); Object.keys(this.peers).forEach(pid => { try { this.peers[pid].onicecandidate = null; this.peers[pid].ontrack = null; this.peers[pid].oniceconnectionstatechange = null; this.peers[pid].close(); } catch(e) { console.error('Error closing peer:', pid, e); } delete this.peers[pid]; }); this.peers = {}; // 6. Remove all remote audio elements document.querySelectorAll('.voice-remote-audio').forEach(audio => { try { audio.pause(); audio.srcObject = null; audio.remove(); } catch(e) {} }); this.remoteAudios = {}; this.pendingCandidates = {}; this.participants = {}; this.speakingUsers.clear(); this.isTalking = false; this.voxActive = false; // Final notify speaking false if (roomToLeave) { const msg = { type: 'voice_speaking', channel_id: roomToLeave, user_id: window.currentUserId, speaking: false }; if (this.ws && this.ws.readyState === WebSocket.OPEN) { try { this.ws.send(JSON.stringify(msg)); } catch(e) {} } } // 7. Update UI this.updateVoiceUI(); } updateVoiceUI() { // We now use a global update mechanism for all channels VoiceChannel.refreshAllVoiceUsers(); if (this.currentChannelId) { 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.style.backgroundColor = '#232428'; controls.innerHTML = `
Voice (${this.settings.mode.toUpperCase()})
`; const sidebar = document.querySelector('.channels-sidebar'); if (sidebar) sidebar.appendChild(controls); const btnLeave = document.getElementById('btn-voice-leave'); if (btnLeave) btnLeave.onclick = () => this.leave(); } } else { const controls = document.querySelector('.voice-controls'); if (controls) controls.remove(); } } updateSpeakingUI(userId, isSpeaking) { if (isSpeaking) { this.speakingUsers.add(userId); } else { this.speakingUsers.delete(userId); } const userEls = document.querySelectorAll(`.voice-user[data-user-id="${userId}"]`); userEls.forEach(el => { const avatar = el.querySelector('.message-avatar'); if (avatar) { avatar.style.boxShadow = isSpeaking ? '0 0 0 2px #23a559' : 'none'; } }); } static async refreshAllVoiceUsers() { try { const resp = await fetch('api_v1_voice.php?action=list_all'); const data = await resp.json(); if (data.success) { // Clear all lists first document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = ''); // Populate based on data Object.keys(data.channels).forEach(channelId => { // Fix: The voice-users-list is a sibling of the container of the voice-item const voiceItem = document.querySelector(`.voice-item[data-channel-id="${channelId}"]`); if (voiceItem) { const container = voiceItem.closest('.channel-item-container'); if (container) { const listEl = container.querySelector('.voice-users-list'); if (listEl) { data.channels[channelId].forEach(p => { const isSpeaking = window.voiceHandler && window.voiceHandler.speakingUsers.has(p.user_id); VoiceChannel.renderUserToUI(listEl, p.user_id, p.display_name || p.username, p.avatar_url, isSpeaking); }); } } } }); } } catch (e) { console.error('Failed to refresh voice users:', e); } } static renderUserToUI(container, userId, username, avatarUrl, isSpeaking = false) { const userEl = document.createElement('div'); userEl.className = 'voice-user small text-muted d-flex align-items-center mb-1'; userEl.dataset.userId = userId; userEl.style.paddingLeft = '8px'; const avatarStyle = avatarUrl ? `background-image: url('${avatarUrl}'); background-size: cover;` : "background-color: #555;"; const boxShadow = isSpeaking ? 'box-shadow: 0 0 0 2px #23a559;' : ''; userEl.innerHTML = `
${username} `; container.appendChild(userEl); } }