diff --git a/api_v1_voice.php b/api_v1_voice.php index d9dee20..2dd8c9b 100644 --- a/api_v1_voice.php +++ b/api_v1_voice.php @@ -34,7 +34,7 @@ function json_out($data, int $code = 200): void { function get_room_participants(string $room): array { $ps = []; try { - $stale_time = now_ms() - 15000; + $stale_time = now_ms() - 30000; // Clean up stale sessions first db()->prepare("DELETE FROM voice_sessions WHERE last_seen < ?")->execute([$stale_time]); @@ -168,7 +168,7 @@ if ($action === "list_all") { // Periodic cleanup of the DB table (stale sessions > 15s) if (rand(1, 10) === 1) { try { - $stale_db_time = now_ms() - 15000; + $stale_db_time = now_ms() - 30000; $stmt = db()->prepare("DELETE FROM voice_sessions WHERE last_seen < ?"); $stmt->execute([$stale_db_time]); } catch (Exception $e) {} @@ -181,7 +181,7 @@ if ($action === "list_all") { JOIN users u ON vs.user_id = u.id WHERE vs.last_seen > ? "); - $stale_db_time = now_ms() - 15000; + $stale_db_time = now_ms() - 30000; $stmt->execute([$stale_db_time]); $sessions = $stmt->fetchAll(PDO::FETCH_ASSOC); @@ -253,7 +253,7 @@ if ($action === "find_whisper_targets") { if (!$target_type || !$target_id) json_out(["error" => "Missing parameters"], 400); try { - $stale_time = now_ms() - 15000; + $stale_time = now_ms() - 30000; if ($target_type === 'user') { $stmt = db()->prepare("SELECT peer_id, name FROM voice_sessions WHERE user_id = ? AND last_seen > ?"); $stmt->execute([(int)$target_id, $stale_time]); diff --git a/assets/js/voice.js b/assets/js/voice.js index 0075823..5b2cb3e 100644 --- a/assets/js/voice.js +++ b/assets/js/voice.js @@ -27,6 +27,8 @@ class VoiceChannel { this.isSelfMuted = false; this.isDeafened = false; + this.peerStates = {}; // userId -> { makingOffer, ignoreOffer } + this.whisperSettings = []; // from DB this.whisperPeers = new Set(); // active whisper target peer_ids this.isWhispering = false; @@ -240,7 +242,7 @@ class VoiceChannel { startPolling() { if (this.pollInterval) clearInterval(this.pollInterval); - this.pollInterval = setInterval(() => this.poll(), 1000); + this.pollInterval = setInterval(() => this.poll(), 500); this.poll(); // Initial poll } @@ -303,6 +305,11 @@ class VoiceChannel { if (this.peers[userId]) return this.peers[userId]; console.log('Creating PeerConnection for:', userId, 'as offeror:', isOfferor); + + if (!this.peerStates[userId]) { + this.peerStates[userId] = { makingOffer: false, ignoreOffer: false }; + } + const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, @@ -314,6 +321,22 @@ class VoiceChannel { pc.oniceconnectionstatechange = () => { console.log(`ICE Connection State with ${userId}: ${pc.iceConnectionState}`); + if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') { + console.log(`ICE failure with ${userId}, attempting to restart...`); + // If it failed, we could try to renegotiate, but for now let's just wait for poll to maybe clean it up + } + }; + + pc.onnegotiationneeded = async () => { + try { + this.peerStates[userId].makingOffer = true; + await pc.setLocalDescription(); + this.sendSignal(userId, { type: 'offer', offer: pc.localDescription }); + } catch (err) { + console.error('onnegotiationneeded error:', err); + } finally { + this.peerStates[userId].makingOffer = false; + } }; if (this.localStream) { @@ -333,6 +356,11 @@ class VoiceChannel { console.log('Received remote track from:', userId, 'Stream count:', event.streams.length); const stream = event.streams[0] || new MediaStream([event.track]); + // Ensure AudioContext is running + if (this.audioContext && this.audioContext.state === 'suspended') { + this.audioContext.resume(); + } + if (this.remoteAudios[userId]) { console.log('Replacing existing audio element for:', userId); this.remoteAudios[userId].pause(); @@ -357,17 +385,13 @@ class VoiceChannel { console.log('Remote audio playing successfully for:', userId); }).catch(e => { console.warn('Autoplay prevented or play failed for:', userId, e); - // In case of autoplay prevention, we might need a user gesture, - // but they just clicked a channel so it should be fine. + // In case of autoplay prevention, we might need a user gesture }); }; - if (isOfferor) { - pc.createOffer().then(offer => { - return pc.setLocalDescription(offer); - }).then(() => { - this.sendSignal(userId, { type: 'offer', offer: pc.localDescription }); - }); + // Manual offer if explicitly requested (though onnegotiationneeded should handle it) + if (isOfferor && pc.signalingState === 'stable') { + pc.onnegotiationneeded(); } return pc; @@ -379,38 +403,68 @@ class VoiceChannel { console.log('Handling signaling from:', from, 'type:', 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, data.is_whisper); - break; + try { + 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, data.is_whisper); + break; + } + } catch (err) { + console.error('Signaling error:', err); } } async handleOffer(from, offer) { const pc = this.createPeerConnection(from, false); + const state = this.peerStates[from]; + + const offerCollision = (offer.type === "offer") && + (state.makingOffer || pc.signalingState !== "stable"); + + // Politeness: higher peer_id is polite + const isPolite = this.myPeerId > from; + state.ignoreOffer = !isPolite && offerCollision; + + if (state.ignoreOffer) { + console.log('Polite peer: ignoring offer from impolite peer to avoid collision', from); + return; + } + await pc.setRemoteDescription(new RTCSessionDescription(offer)); - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - this.sendSignal(from, { type: 'answer', answer: pc.localDescription }); + if (offer.type === "offer") { + await pc.setLocalDescription(); + this.sendSignal(from, { type: 'answer', answer: pc.localDescription }); + } } async handleAnswer(from, answer) { const pc = this.peers[from]; - if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer)); + 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)); + const state = this.peerStates[from]; + try { + if (pc) { + await pc.addIceCandidate(new RTCIceCandidate(candidate)); + } + } catch (err) { + if (!state || !state.ignoreOffer) { + console.warn('Failed to add ICE candidate', err); + } + } } setupVOX() {