From b9524fbfd44228bae7c6f3bfa5ac67d749425738 Mon Sep 17 00:00:00 2001 From: Adam Lamers Date: Mon, 30 Mar 2026 17:01:33 -0400 Subject: [PATCH] voice channel audio working again --- src/App.css | 17 +- src/App.tsx | 1 - src/chat/ChatContainer.tsx | 60 ++- src/chat/components/ChannelList.tsx | 60 +-- src/chat/services/index.ts | 2 + src/chat/services/webrtc/types.ts | 39 ++ src/chat/services/webrtc/useLocalMedia.ts | 145 ++++++ src/chat/services/webrtc/usePeerManager.ts | 239 ++++++++++ src/chat/services/webrtc/useSignaling.ts | 188 ++++++++ src/chat/services/webrtc/useWebRTC.ts | 246 ++++++++++ src/useWebRTC.ts | 526 --------------------- 11 files changed, 928 insertions(+), 595 deletions(-) create mode 100644 src/chat/services/webrtc/types.ts create mode 100644 src/chat/services/webrtc/useLocalMedia.ts create mode 100644 src/chat/services/webrtc/usePeerManager.ts create mode 100644 src/chat/services/webrtc/useSignaling.ts create mode 100644 src/chat/services/webrtc/useWebRTC.ts delete mode 100644 src/useWebRTC.ts diff --git a/src/App.css b/src/App.css index 3e22198..b3bacdc 100644 --- a/src/App.css +++ b/src/App.css @@ -94,11 +94,14 @@ body { width: var(--channel-sidebar-width); height: 100%; flex-shrink: 0; + position: relative; + z-index: 100; } .channel-sidebar { flex: 1; overflow-y: auto; + overflow-x: visible; display: flex; flex-direction: column; } @@ -488,7 +491,7 @@ body { right: 8px; } -.video-tile:hover .video-controls, +.video-tile:hover .video-controls, .video-tile:hover .tile-actions-right { opacity: 1; } @@ -623,10 +626,10 @@ body { /* Connection Popover */ .connection-popover { position: absolute; - top: 0; - left: 100%; - margin-left: 12px; - width: 220px; + top: 100%; + left: 12px; + margin-top: 4px; + width: 216px; background-color: var(--background-floating, #1e1f22); border-radius: 8px; padding: 12px; @@ -640,8 +643,8 @@ body { .connection-popover::before { content: ''; position: absolute; - top: 8px; - left: -6px; + top: -6px; + left: 20px; width: 12px; height: 12px; background-color: var(--background-floating); diff --git a/src/App.tsx b/src/App.tsx index 42f4395..b3731f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,6 @@ import './App.css'; // import { Identity } from 'spacetimedb'; // import { useAuth } from "react-oidc-context"; // import { TOKEN_KEY } from './main'; -// import { useWebRTC } from './useWebRTC'; // Import the new ChatContainer component import { ChatContainer } from './chat'; // Import from index.ts diff --git a/src/chat/ChatContainer.tsx b/src/chat/ChatContainer.tsx index 5373255..ee9790f 100644 --- a/src/chat/ChatContainer.tsx +++ b/src/chat/ChatContainer.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { useChat } from './services'; +import { useChat, useWebRTC } from './services'; import ServerList from './components/ServerList'; import ChannelList from './components/ChannelList'; import MessageList from './components/MessageList'; @@ -9,9 +9,7 @@ import ThreadView from './components/ThreadView'; import ServerDiscovery from './components/ServerDiscovery'; import { VideoGrid } from './components/VideoGrid'; import { SettingsPanel } from './components/SettingsPanel'; -import { useSpacetimeDB, useTable } from 'spacetimedb/react'; -import { tables } from '../module_bindings'; -import useWebRTC from '../useWebRTC'; +import { useSpacetimeDB } from 'spacetimedb/react'; const ChatContainer: React.FC = () => { const chat = useChat(); @@ -19,12 +17,12 @@ const ChatContainer: React.FC = () => { const [showSettings, setShowSettings] = useState(false); const [showMemberList, setShowMemberList] = useState(true); - const { - peerStatuses, - peers, - localScreenStream, - startScreenShare, - stopScreenShare, + const { + peerStatuses, + peers, + localScreenStream, + startScreenShare, + stopScreenShare, isSharingScreen, startWatching, stopWatching, @@ -42,7 +40,7 @@ const ChatContainer: React.FC = () => { return (
- { setShowDiscoveryModal={chat.setShowDiscoveryModal} handleLeaveServer={chat.handleLeaveServer} /> - +
- {
-
- - -
- +
{chat.isActiveChannelVoice && chat.connectedVoiceChannel?.id === chat.activeChannel?.id && ( -
- + {chat.isActiveChannelVoice ? ( - { /> ) : ( <> - { handleStartThread={chat.handleStartThread} isFullyAuthenticated={chat.isFullyAuthenticated} /> - { {chat.activeThreadId ? ( - { /> ) : ( showMemberList && ( - { )} {chat.showDiscoveryModal && ( - chat.setShowDiscoveryModal(false)} diff --git a/src/chat/components/ChannelList.tsx b/src/chat/components/ChannelList.tsx index 783e145..7f1429b 100644 --- a/src/chat/components/ChannelList.tsx +++ b/src/chat/components/ChannelList.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Identity } from "spacetimedb"; import * as Types from "../../module_bindings/types"; -import { WebRTCStats } from "../../useWebRTC"; +import { WebRTCStats } from "../services"; interface ChannelListProps { activeServerId: bigint | null; @@ -58,7 +58,7 @@ const ConnectionPopover: React.FC<{ stats?: WebRTCStats, status: string, name: s {name} {status} - + {isMe ? (
Local connection (sending only)
) : stats ? ( @@ -160,9 +160,9 @@ export const ChannelList: React.FC = ({ ๐Ÿ”Š {channel.name} - + {/* Voice Channel Members */} -
+
{voiceStates.filter(vs => vs.channelId === channel.id).map(vs => { const peerIdHex = vs.identity.toHexString(); const isMe = identity?.isEqual(vs.identity); @@ -181,27 +181,27 @@ export const ChannelList: React.FC = ({ else if (videoStatusColor === 'yellow' && finalStatusColor === 'green') finalStatusColor = 'yellow'; return ( -
-
= ({
{getUsername(vs.identity, users)} {isSharing && ( - LIVE )} -
setHoveredPeer(peerIdHex)} onMouseLeave={() => setHoveredPeer(null)} > @@ -240,7 +240,7 @@ export const ChannelList: React.FC = ({
{hoveredPeer === peerIdHex && ( - void; + toggleDeafen: () => void; + startScreenShare: (peerManager: any) => Promise; + stopScreenShare: (peerManager: any) => void; + requestMic: () => Promise; + releaseMic: () => void; +} diff --git a/src/chat/services/webrtc/useLocalMedia.ts b/src/chat/services/webrtc/useLocalMedia.ts new file mode 100644 index 0000000..4066f5a --- /dev/null +++ b/src/chat/services/webrtc/useLocalMedia.ts @@ -0,0 +1,145 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { useReducer } from "spacetimedb/react"; +import { reducers } from "../../../module_bindings"; + +export const useLocalMedia = () => { + const [localStream, setLocalStream] = useState(null); + const [localScreenStream, setLocalScreenStream] = useState(null); + const [isMuted, setIsMuted] = useState(false); + const [isDeafened, setIsDeafened] = useState(false); + const [isTalking, setIsTalking] = useState(false); + + const localStreamRef = useRef(null); + const localScreenStreamRef = useRef(null); + const isTalkingRef = useRef(false); + + const setTalking = useReducer(reducers.setTalking); + const setSharingScreen = useReducer(reducers.setSharingScreen); + + const toggleMute = useCallback(() => setIsMuted(prev => !prev), []); + const toggleDeafen = useCallback(() => setIsDeafened(prev => !prev), []); + + const requestMic = useCallback(async () => { + if (localStreamRef.current) return; + try { + console.log("[WebRTC] Requesting mic permission..."); + const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + setLocalStream(stream); + localStreamRef.current = stream; + } catch (err) { + console.error("[WebRTC] Failed to get mic:", err); + } + }, []); + + const releaseMic = useCallback(() => { + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach(track => track.stop()); + setLocalStream(null); + localStreamRef.current = null; + } + }, []); + + const startScreenShare = useCallback(async (onTrackReady: (track: MediaStreamTrack) => void) => { + try { + console.log("[WebRTC] Requesting screen share..."); + const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }); + setLocalScreenStream(stream); + localScreenStreamRef.current = stream; + setSharingScreen({ sharing: true }); + + const videoTrack = stream.getVideoTracks()[0]; + if (videoTrack) { + onTrackReady(videoTrack); + videoTrack.onended = () => stopScreenShare(() => onTrackReady(null as any)); + } + } catch (err) { + console.error("[WebRTC] Failed to start screen share:", err); + } + }, [setSharingScreen]); + + const stopScreenShare = useCallback((onTrackCleared: (track: MediaStreamTrack | null) => void) => { + if (localScreenStreamRef.current) { + localScreenStreamRef.current.getTracks().forEach(track => track.stop()); + setLocalScreenStream(null); + localScreenStreamRef.current = null; + setSharingScreen({ sharing: false }); + onTrackCleared(null); + } + }, [setSharingScreen]); + + // Handle Mute/Deafen effect on tracks + useEffect(() => { + if (localStreamRef.current) { + localStreamRef.current.getAudioTracks().forEach(track => { + track.enabled = !isMuted && !isDeafened; + }); + } + }, [isMuted, isDeafened]); + + // Voice Activity Detection + useEffect(() => { + if (!localStream) { + if (isTalkingRef.current) { + setTalking({ talking: false }); + isTalkingRef.current = false; + setIsTalking(false); + } + return; + } + + const audioContext = new AudioContext(); + const analyser = audioContext.createAnalyser(); + const source = audioContext.createMediaStreamSource(localStream); + analyser.fftSize = 256; + source.connect(analyser); + const dataArray = new Uint8Array(analyser.frequencyBinCount); + const threshold = 15; + let silenceFrames = 0; + const maxSilenceFrames = 10; + + const checkAudio = setInterval(() => { + analyser.getByteFrequencyData(dataArray); + let sum = 0; + for (let i = 0; i < dataArray.length; i++) sum += dataArray[i]; + const average = sum / dataArray.length; + + if (average > threshold) { + silenceFrames = 0; + if (!isTalkingRef.current) { + setTalking({ talking: true }); + isTalkingRef.current = true; + setIsTalking(true); + } + } else { + silenceFrames++; + if (silenceFrames > maxSilenceFrames && isTalkingRef.current) { + setTalking({ talking: false }); + isTalkingRef.current = false; + setIsTalking(false); + } + } + }, 50); + + return () => { + clearInterval(checkAudio); + audioContext.close(); + }; + }, [localStream, setTalking]); + + return { + localStream, + localScreenStream, + isMuted, + isDeafened, + isTalking, + isSharingScreen: !!localScreenStream, + toggleMute, + toggleDeafen, + startScreenShare, + stopScreenShare, + requestMic, + releaseMic, + localStreamRef, + localScreenStreamRef + }; +}; diff --git a/src/chat/services/webrtc/usePeerManager.ts b/src/chat/services/webrtc/usePeerManager.ts new file mode 100644 index 0000000..8900d13 --- /dev/null +++ b/src/chat/services/webrtc/usePeerManager.ts @@ -0,0 +1,239 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { Identity } from "spacetimedb"; +import { Peer, WebRTCStats } from "./types"; + +const ICE_SERVERS: RTCConfiguration = { + iceServers: [{ urls: "stun:stun.l.google.com:19302" }], +}; + +export const usePeerManager = ( + identity: Identity | null, + isDeafened: boolean, + localStreamRef: React.MutableRefObject, + localScreenStreamRef: React.MutableRefObject, + onNegotiationNeeded: (peerIdHex: string, pc: RTCPeerConnection) => void, + onIceCandidate: (peerIdHex: string, candidate: RTCIceCandidate) => void +) => { + const [peers, setPeers] = useState>(new Map()); + const [peerStatuses, setPeerStatuses] = useState>(new Map()); + const [peerStats, setPeerStats] = useState>(new Map()); + const peersRef = useRef>(new Map()); + const peerStatsRef = useRef>(new Map()); + + const getPeer = useCallback((peerIdHex: string) => peersRef.current.get(peerIdHex), []); + + const createPeerConnection = useCallback((peerIdHex: string) => { + if (peersRef.current.has(peerIdHex)) return peersRef.current.get(peerIdHex)!.pc; + + if (identity && peerIdHex === identity.toHexString()) { + console.warn(`[WebRTC] Attempted to create a PeerConnection to self (${peerIdHex}). Ignoring.`); + return null as any; // Should not happen with proper filtering + } + + console.log(`[WebRTC] Creating new PeerConnection for ${peerIdHex}`); + + const pc = new RTCPeerConnection(ICE_SERVERS); + + // Bind handlers BEFORE adding transceivers to catch early negotiationneeded + pc.onnegotiationneeded = () => { + console.log(`[WebRTC] onnegotiationneeded fired for ${peerIdHex}`); + onNegotiationNeeded(peerIdHex, pc); + }; + + pc.onicecandidate = (event) => { + if (event.candidate) { + onIceCandidate(peerIdHex, event.candidate); + } + }; + + pc.oniceconnectionstatechange = () => { + console.log(`[WebRTC] ICE state for ${peerIdHex}: ${pc.iceConnectionState}`); + setPeerStatuses(prev => { + const next = new Map(prev); + next.set(peerIdHex, pc.iceConnectionState); + return next; + }); + + if (pc.iceConnectionState === 'failed') { + console.log(`[WebRTC] ICE failed for ${peerIdHex}, closing peer for retry`); + closePeer(peerIdHex); + } + }; + + pc.onconnectionstatechange = () => { + console.log(`[WebRTC] Connection state for ${peerIdHex}: ${pc.connectionState}`); + if (pc.connectionState === 'failed' || pc.connectionState === 'closed') { + console.log(`[WebRTC] Connection ${pc.connectionState} for ${peerIdHex}, cleaning up`); + closePeer(peerIdHex); + } + }; + + pc.ontrack = (event) => { + console.log(`[WebRTC] Received track from ${peerIdHex}: ${event.track.kind} (id: ${event.track.id})`); + setPeers(prev => { + const next = new Map(prev); + const existingPeer = { ...(next.get(peerIdHex) || { pc }) }; + + if (event.track.kind === 'audio') { + if (!existingPeer.audio) { + existingPeer.audio = new Audio(); + existingPeer.audio.autoplay = true; + existingPeer.audio.muted = isDeafened; + } + + const currentAudioStream = (existingPeer.audio.srcObject instanceof MediaStream) + ? existingPeer.audio.srcObject + : new MediaStream(); + + if (!currentAudioStream.getTracks().find(t => t.id === event.track.id)) { + currentAudioStream.addTrack(event.track); + } + + if (existingPeer.audio.srcObject !== currentAudioStream) { + existingPeer.audio.srcObject = currentAudioStream; + } + + existingPeer.audio.play().catch(e => { + if (e.name !== 'AbortError') console.error(`[WebRTC] Error playing audio for ${peerIdHex}`, e); + }); + } else if (event.track.kind === 'video') { + + const currentVideoStream = existingPeer.videoStream || new MediaStream(); + if (!currentVideoStream.getTracks().find(t => t.id === event.track.id)) { + currentVideoStream.addTrack(event.track); + } + // Force a new MediaStream object to trigger React re-render + existingPeer.videoStream = new MediaStream(currentVideoStream.getTracks()); + } + + next.set(peerIdHex, existingPeer); + peersRef.current = next; + return next; + }); + }; + + // Fixed transceivers for stability + pc.addTransceiver('audio', { direction: 'sendrecv' }); + pc.addTransceiver('video', { direction: 'sendrecv' }); + + // Initialize transceivers with local tracks if available + const transceivers = pc.getTransceivers(); + if (localStreamRef.current) { + const audioTrack = localStreamRef.current.getAudioTracks()[0]; + if (audioTrack) { + console.log(`[WebRTC] Attaching local audio track to new connection for ${peerIdHex}`); + transceivers[0].sender.replaceTrack(audioTrack); + } + } + if (localScreenStreamRef.current) { + const videoTrack = localScreenStreamRef.current.getVideoTracks()[0]; + if (videoTrack) { + console.log(`[WebRTC] Attaching local video track to new connection for ${peerIdHex}`); + transceivers[1].sender.replaceTrack(videoTrack); + } + } + + peersRef.current.set(peerIdHex, { pc }); + setPeers(new Map(peersRef.current)); + return pc; + }, [localStreamRef, localScreenStreamRef, onNegotiationNeeded, onIceCandidate]); + + + // Sync isDeafened state to all peer audio elements + useEffect(() => { + peersRef.current.forEach(peer => { + if (peer.audio) { + peer.audio.muted = isDeafened; + } + }); + }, [isDeafened]); + + const closePeer = useCallback((peerIdHex: string) => { + + const peer = peersRef.current.get(peerIdHex); + if (peer) { + peer.pc.close(); + if (peer.audio) { + peer.audio.pause(); + peer.audio.srcObject = null; + } + peersRef.current.delete(peerIdHex); + setPeers(new Map(peersRef.current)); + setPeerStatuses(prev => { + const next = new Map(prev); + next.delete(peerIdHex); + return next; + }); + } + }, []); + + // Stats Polling + useEffect(() => { + if (peers.size === 0) { + if (peerStatsRef.current.size > 0) { + peerStatsRef.current = new Map(); + setPeerStats(new Map()); + } + return; + } + + const interval = setInterval(async () => { + const newStats = new Map(peerStatsRef.current); + for (const [peerIdHex, peer] of peers.entries()) { + try { + const stats = await peer.pc.getStats(); + const prevStats = peerStatsRef.current.get(peerIdHex); + const currentStats: WebRTCStats = { + audio: { bytesReceived: 0, jitter: 0, packetsLost: 0, bitrate: 0 }, + video: { bytesReceived: 0, frameWidth: 0, frameHeight: 0, framesPerSecond: 0, bitrate: 0 }, + timestamp: Date.now() + }; + + stats.forEach(report => { + if (report.type === 'inbound-rtp') { + const kind = report.kind; + if (kind === 'audio' || kind === 'video') { + const target = kind === 'audio' ? currentStats.audio : currentStats.video; + target.bytesReceived = report.bytesReceived || 0; + if (kind === 'audio') { + currentStats.audio.jitter = report.jitter || 0; + currentStats.audio.packetsLost = report.packetsLost || 0; + } else { + currentStats.video.frameWidth = report.frameWidth || 0; + currentStats.video.frameHeight = report.frameHeight || 0; + currentStats.video.framesPerSecond = report.framesPerSecond || 0; + } + + if (prevStats) { + const prevTarget = kind === 'audio' ? prevStats.audio : prevStats.video; + const deltaBytes = target.bytesReceived - prevTarget.bytesReceived; + const deltaTime = (currentStats.timestamp - prevStats.timestamp) / 1000; + if (deltaTime > 0) { + target.bitrate = Math.max(0, deltaBytes * 8 / deltaTime); + } + } + } + } + }); + newStats.set(peerIdHex, currentStats); + } catch (e) { + console.warn(`[WebRTC] Failed to get stats for ${peerIdHex}`, e); + } + } + peerStatsRef.current = newStats; + setPeerStats(newStats); + }, 2000); + + return () => clearInterval(interval); + }, [peers]); + + return { + peers, + peerStatuses, + peerStats, + createPeerConnection, + closePeer, + getPeer, + peersRef + }; +}; diff --git a/src/chat/services/webrtc/useSignaling.ts b/src/chat/services/webrtc/useSignaling.ts new file mode 100644 index 0000000..a025015 --- /dev/null +++ b/src/chat/services/webrtc/useSignaling.ts @@ -0,0 +1,188 @@ +import { useEffect, useRef, useCallback } from "react"; +import { Identity } from "spacetimedb"; +import { useTable, useReducer } from "spacetimedb/react"; +import { tables, reducers } from "../../../module_bindings"; + +export const useSignaling = ( + identity: Identity | null, + connectedChannelId: bigint | undefined, + createPeerConnection: (peerIdHex: string) => RTCPeerConnection, + getPeer: (peerIdHex: string) => any, + makingOfferRef: React.MutableRefObject>, + ignoreOfferRef: React.MutableRefObject> +) => { + const [offers] = useTable(tables.sdp_offer); + const [answers] = useTable(tables.sdp_answer); + const [iceCandidates] = useTable(tables.ice_candidate); + + const sendSdpAnswer = useReducer(reducers.sendSdpAnswer); + + const processedOffersRef = useRef>(new Set()); + const processedAnswersRef = useRef>(new Set()); + const processedCandidatesRef = useRef>(new Set()); + const candidateQueueRef = useRef>(new Map()); + + + const drainCandidateQueue = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => { + const queue = candidateQueueRef.current.get(peerIdHex) || []; + if (queue.length === 0) return; + + // Safety: ensure we have a remote description before draining + if (!pc.remoteDescription) { + console.warn(`[WebRTC] Attempted to drain candidates for ${peerIdHex} but no remote description exists`); + return; + } + + console.log(`[WebRTC] Draining ${queue.length} queued candidates for ${peerIdHex}`); + for (const cand of queue) { + try { + await pc.addIceCandidate(new RTCIceCandidate(cand)); + } catch (e) { + console.warn(`[WebRTC] Error adding queued ICE for ${peerIdHex}`, e); + } + } + candidateQueueRef.current.set(peerIdHex, []); + }, []); + + // Handle Offers + useEffect(() => { + if (!connectedChannelId || !identity) return; + const myOffers = offers.filter(o => + o.receiver.isEqual(identity) && + !o.sender.isEqual(identity) && + o.channelId === connectedChannelId + ); + + const processOffers = async () => { + for (const offerRow of myOffers) { + if (processedOffersRef.current.has(offerRow.id)) continue; + // Mark as processed immediately to prevent duplicate processing during async gaps + processedOffersRef.current.add(offerRow.id); + + const peerIdHex = offerRow.sender.toHexString(); + console.log(`[WebRTC] Received offer from ${peerIdHex}`); + const pc = createPeerConnection(peerIdHex); + if (!pc) continue; + const offer = JSON.parse(offerRow.sdp); + + try { + const isPolite = identity.toHexString() < peerIdHex; + const makingOffer = makingOfferRef.current.get(peerIdHex) || false; + const offerCollision = (pc.signalingState !== "stable") || makingOffer; + + const ignoreOffer = !isPolite && offerCollision; + ignoreOfferRef.current.set(peerIdHex, ignoreOffer); + if (ignoreOffer) { + console.log(`[WebRTC] Ignoring offer collision from ${peerIdHex} (Impolite)`); + continue; + } + + if (offerCollision) { + console.log(`[WebRTC] Handling offer collision from ${peerIdHex} (Polite), rolling back...`); + await pc.setLocalDescription({ type: "rollback" as RTCSdpType }); + } + + console.log(`[WebRTC] Setting remote description for ${peerIdHex}`); + await pc.setRemoteDescription(new RTCSessionDescription(offer)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + console.log(`[WebRTC] Sending answer to ${peerIdHex}`); + sendSdpAnswer({ receiver: offerRow.sender, sdp: JSON.stringify(answer), channelId: connectedChannelId }); + + await drainCandidateQueue(peerIdHex, pc); + } catch (e) { + console.error(`[WebRTC] Error handling offer from ${peerIdHex}`, e); + } + } + }; + processOffers(); + }, [offers, connectedChannelId, identity, createPeerConnection, sendSdpAnswer, drainCandidateQueue]); + + // Handle Answers + useEffect(() => { + if (!connectedChannelId || !identity) return; + const myAnswers = answers.filter(a => + a.receiver.isEqual(identity) && + !a.sender.isEqual(identity) && + a.channelId === connectedChannelId + ); + + const processAnswers = async () => { + for (const answerRow of myAnswers) { + if (processedAnswersRef.current.has(answerRow.id)) continue; + processedAnswersRef.current.add(answerRow.id); + + const peerIdHex = answerRow.sender.toHexString(); + const peer = getPeer(peerIdHex); + if (peer) { + try { + console.log(`[WebRTC] Received answer from ${peerIdHex}`); + const answer = JSON.parse(answerRow.sdp); + await peer.pc.setRemoteDescription(new RTCSessionDescription(answer)); + await drainCandidateQueue(peerIdHex, peer.pc); + } catch (e) { console.error(`[WebRTC] Error handling answer from ${peerIdHex}`, e); } + } else { + console.warn(`[WebRTC] Received answer from ${peerIdHex} but no PeerConnection exists`); + } + } + }; + processAnswers(); + }, [answers, connectedChannelId, identity, getPeer, drainCandidateQueue]); + + // Handle ICE Candidates + useEffect(() => { + if (!connectedChannelId || !identity) return; + const myCandidates = iceCandidates.filter(c => + c.receiver.isEqual(identity) && + !c.sender.isEqual(identity) && + c.channelId === connectedChannelId + ); + + const processCandidates = async () => { + for (const candRow of myCandidates) { + if (processedCandidatesRef.current.has(candRow.id)) continue; + processedCandidatesRef.current.add(candRow.id); + + const peerIdHex = candRow.sender.toHexString(); + // Ensure PeerConnection exists if we get a candidate + const pc = createPeerConnection(peerIdHex); + if (!pc) continue; + + try { + const ignoreOffer = ignoreOfferRef.current.get(peerIdHex) || false; + const candidate = JSON.parse(candRow.candidate); + + if (pc.remoteDescription) { + console.log(`[WebRTC] Adding ICE candidate from ${peerIdHex}`); + await pc.addIceCandidate(new RTCIceCandidate(candidate)); + } else if (!ignoreOffer) { + console.log(`[WebRTC] Queueing ICE candidate from ${peerIdHex}`); + const queue = candidateQueueRef.current.get(peerIdHex) || []; + queue.push(candidate); + candidateQueueRef.current.set(peerIdHex, queue); + } else { + console.log(`[WebRTC] Ignoring ICE candidate from ${peerIdHex} (ignoreOffer=true)`); + } + } catch (e) { console.error(`[WebRTC] Error handling ICE from ${peerIdHex}`, e); } + } + }; + processCandidates(); + }, [iceCandidates, connectedChannelId, identity, createPeerConnection]); + + + + + const clearSignalingState = useCallback(() => { + processedOffersRef.current.clear(); + processedAnswersRef.current.clear(); + processedCandidatesRef.current.clear(); + makingOfferRef.current.clear(); + ignoreOfferRef.current.clear(); + candidateQueueRef.current.clear(); + }, []); + + return { + makingOfferRef, + clearSignalingState + }; +}; diff --git a/src/chat/services/webrtc/useWebRTC.ts b/src/chat/services/webrtc/useWebRTC.ts new file mode 100644 index 0000000..67b2c54 --- /dev/null +++ b/src/chat/services/webrtc/useWebRTC.ts @@ -0,0 +1,246 @@ +import { useEffect, useCallback, useMemo, useRef } from "react"; +import { Identity } from "spacetimedb"; +import { useTable, useReducer, useSpacetimeDB } from "spacetimedb/react"; +import { tables, reducers } from "../../../module_bindings"; +import { useLocalMedia } from "./useLocalMedia"; +import { usePeerManager } from "./usePeerManager"; +import { useSignaling } from "./useSignaling"; + +export const useWebRTC = (connectedChannelId: bigint | undefined) => { + const { identity } = useSpacetimeDB(); + const [voiceStates] = useTable(tables.voice_state); + const [watching] = useTable(tables.watching); + + const sendSdpOffer = useReducer(reducers.sendSdpOffer); + const sendIceCandidate = useReducer(reducers.sendIceCandidate); + const startWatchingReducer = useReducer(reducers.startWatching); + const stopWatchingReducer = useReducer(reducers.stopWatching); + + // Refs for signaling state to avoid circular dependencies and stale closures + const makingOfferRef = useRef>(new Map()); + const ignoreOfferRef = useRef>(new Map()); + const connectedChannelIdRef = useRef(connectedChannelId); + + useEffect(() => { + connectedChannelIdRef.current = connectedChannelId; + }, [connectedChannelId]); + + const { + localStream, + localScreenStream, + isMuted, + isDeafened, + isTalking, + isSharingScreen, + toggleMute, + toggleDeafen, + startScreenShare: startLocalScreenShare, + stopScreenShare: stopLocalScreenShare, + requestMic, + releaseMic, + localStreamRef, + localScreenStreamRef + } = useLocalMedia(); + + const onNegotiationNeeded = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => { + // Always check the LATEST channel ID from ref + const channelId = connectedChannelIdRef.current; + const isMakingOffer = makingOfferRef.current.get(peerIdHex); + + if (!channelId || pc.signalingState !== 'stable' || isMakingOffer) { + console.log(`[WebRTC] Skipping negotiation for ${peerIdHex}: channel=${!!channelId}, state=${pc.signalingState}, makingOffer=${isMakingOffer}`); + return; + } + + try { + makingOfferRef.current.set(peerIdHex, true); + console.log(`[WebRTC] Negotiation needed for ${peerIdHex}, creating offer...`); + await pc.setLocalDescription(); + console.log(`[WebRTC] Sending offer to ${peerIdHex}`); + sendSdpOffer({ + receiver: Identity.fromString(peerIdHex), + sdp: JSON.stringify(pc.localDescription), + channelId + }); + } catch (e) { + console.error(`[WebRTC] Error during negotiation for ${peerIdHex}`, e); + } finally { + makingOfferRef.current.set(peerIdHex, false); + } + }, [sendSdpOffer]); + + + const onIceCandidate = useCallback((peerIdHex: string, candidate: RTCIceCandidate) => { + const channelId = connectedChannelIdRef.current; + if (channelId) { + sendIceCandidate({ + receiver: Identity.fromString(peerIdHex), + candidate: JSON.stringify(candidate), + channelId + }); + } + }, [sendIceCandidate]); + + const { + peers, + peerStatuses, + peerStats, + createPeerConnection, + closePeer, + getPeer, + peersRef + } = usePeerManager( + identity, + isDeafened, + localStreamRef, + localScreenStreamRef, + onNegotiationNeeded, + onIceCandidate + ); + + const { + clearSignalingState + } = useSignaling( + identity, + connectedChannelId, + createPeerConnection, + getPeer, + makingOfferRef, + ignoreOfferRef + ); + + // Sync local media to existing peers + useEffect(() => { + const audioTrack = localStream?.getAudioTracks()[0] || null; + peersRef.current.forEach(async (peer, peerIdHex) => { + const transceivers = peer.pc.getTransceivers(); + if (transceivers[0] && transceivers[0].sender.track !== audioTrack) { + console.log(`[WebRTC] Syncing audio track to peer ${peerIdHex}`); + try { + await transceivers[0].sender.replaceTrack(audioTrack); + if (peer.pc.signalingState === 'stable') { + onNegotiationNeeded(peerIdHex, peer.pc); + } + } catch (e) { + console.error(`[WebRTC] Error replacing audio track for ${peerIdHex}`, e); + } + } + }); + }, [localStream, peers, onNegotiationNeeded]); + + useEffect(() => { + const videoTrack = localScreenStream?.getVideoTracks()[0] || null; + peersRef.current.forEach(async (peer, peerIdHex) => { + const transceivers = peer.pc.getTransceivers(); + if (transceivers[1] && transceivers[1].sender.track !== videoTrack) { + console.log(`[WebRTC] Syncing video track to peer ${peerIdHex}`); + try { + await transceivers[1].sender.replaceTrack(videoTrack); + if (peer.pc.signalingState === 'stable') { + onNegotiationNeeded(peerIdHex, peer.pc); + } + } catch (e) { + console.error(`[WebRTC] Error replacing video track for ${peerIdHex}`, e); + } + } + }); + }, [localScreenStream, peers, onNegotiationNeeded]); + + + + // Determine who to connect to + const peersToConnect = useMemo(() => { + if (!identity || !connectedChannelId) return new Set(); + const peerIds = new Set(); + voiceStates.forEach(vs => { + if (vs.channelId === connectedChannelId && !vs.identity.isEqual(identity)) { + peerIds.add(vs.identity.toHexString()); + } + }); + watching.forEach(w => { + if (w.watcher.isEqual(identity)) { + peerIds.add(w.watchee.toHexString()); + } else if (w.watchee.isEqual(identity)) { + peerIds.add(w.watcher.toHexString()); + } + }); + return peerIds; + }, [voiceStates, watching, identity, connectedChannelId]); + + // Peer Lifecycle Orchestration + useEffect(() => { + if (!connectedChannelId || !identity) { + // Cleanup all + if (peersRef.current.size > 0) { + console.log("[WebRTC] Cleaning up all peer connections"); + peersRef.current.forEach((_, peerIdHex) => closePeer(peerIdHex)); + } + releaseMic(); + clearSignalingState(); + return; + } + + // Always clear signaling state when connectedChannelId changes to avoid stale row processing + clearSignalingState(); + + // Connect to new peers + peersToConnect.forEach(peerIdHex => { + if (!peersRef.current.has(peerIdHex)) { + createPeerConnection(peerIdHex); + } + }); + + // Cleanup disconnected peers + peersRef.current.forEach((_, peerIdHex) => { + if (!peersToConnect.has(peerIdHex)) { + closePeer(peerIdHex); + } + }); + + requestMic(); + }, [peersToConnect, connectedChannelId, identity, createPeerConnection, closePeer, requestMic, releaseMic, clearSignalingState]); + + // Screen Share Actions + const startScreenShare = useCallback(() => { + startLocalScreenShare((track) => { + // Handled by localScreenStream effect + }); + }, [startLocalScreenShare]); + + const stopScreenShare = useCallback(() => { + stopLocalScreenShare((track) => { + // Handled by localScreenStream effect + }); + }, [stopLocalScreenShare]); + + const startWatching = useCallback((peerIdentity: Identity) => { + if (connectedChannelId) { + startWatchingReducer({ watchee: peerIdentity, channelId: connectedChannelId }); + } + }, [connectedChannelId, startWatchingReducer]); + + const stopWatching = useCallback((peerIdentity: Identity) => { + stopWatchingReducer({ watchee: peerIdentity }); + }, [stopWatchingReducer]); + + return { + localStream, + localScreenStream, + peerStatuses, + peers, + startScreenShare, + stopScreenShare, + isSharingScreen, + startWatching, + stopWatching, + watching, + isMuted, + isDeafened, + toggleMute, + toggleDeafen, + peerStats + }; +}; + +export default useWebRTC; + diff --git a/src/useWebRTC.ts b/src/useWebRTC.ts deleted file mode 100644 index 19e236d..0000000 --- a/src/useWebRTC.ts +++ /dev/null @@ -1,526 +0,0 @@ -import { useEffect, useCallback, useState, useRef } from "react"; -import { Identity } from "spacetimedb"; -import { useTable, useReducer, useSpacetimeDB } from "spacetimedb/react"; -import { tables, reducers } from "./module_bindings"; -import * as Types from "./module_bindings/types"; - -const ICE_SERVERS: RTCConfiguration = { - iceServers: [{ urls: "stun:stun.l.google.com:19302" }], -}; - -interface Peer { - pc: RTCPeerConnection; - audio?: HTMLAudioElement; - videoStream?: MediaStream; -} - -export interface WebRTCStats { - audio: { - bytesReceived: number; - jitter: number; - packetsLost: number; - bitrate: number; - }; - video: { - bytesReceived: number; - frameWidth: number; - frameHeight: number; - framesPerSecond: number; - bitrate: number; - }; - timestamp: number; -} - -export const useWebRTC = (connectedChannelId: bigint | undefined) => { - const { identity } = useSpacetimeDB(); - const [localStream, setLocalStream] = useState(null); - const [localScreenStream, setLocalScreenStream] = useState(null); - const [peers, setPeers] = useState>(new Map()); - const [peerStatuses, setPeerStatuses] = useState>(new Map()); - const [peerStats, setPeerStats] = useState>(new Map()); - const peerStatsRef = useRef>(new Map()); - const [isMuted, setIsMuted] = useState(false); - const [isDeafened, setIsDeafened] = useState(false); - - const sendSdpOffer = useReducer(reducers.sendSdpOffer); - const sendSdpAnswer = useReducer(reducers.sendSdpAnswer); - const sendIceCandidate = useReducer(reducers.sendIceCandidate); - const setTalking = useReducer(reducers.setTalking); - const setSharingScreen = useReducer(reducers.setSharingScreen); - const startWatchingReducer = useReducer(reducers.startWatching); - const stopWatchingReducer = useReducer(reducers.stopWatching); - - const [offers] = useTable(tables.sdp_offer); - const [answers] = useTable(tables.sdp_answer); - const [iceCandidates] = useTable(tables.ice_candidate); - const [voiceStates] = useTable(tables.voice_state); - const [watching] = useTable(tables.watching); - - const localStreamRef = useRef(null); - const localScreenStreamRef = useRef(null); - const peersRef = useRef>(new Map()); - const isTalkingRef = useRef(false); - - // Mute/Deafen Logic - useEffect(() => { - if (localStream) { - localStream.getAudioTracks().forEach(track => { - track.enabled = !isMuted && !isDeafened; - }); - } - }, [isMuted, isDeafened, localStream]); - - useEffect(() => { - peers.forEach(peer => { - if (peer.audio) { - peer.audio.muted = isDeafened; - } - }); - }, [isDeafened, peers]); - - // Stats Polling - useEffect(() => { - if (peers.size === 0) { - if (peerStatsRef.current.size > 0) { - peerStatsRef.current = new Map(); - setPeerStats(new Map()); - } - return; - } - - const interval = setInterval(async () => { - const newStats = new Map(peerStatsRef.current); - - for (const [peerIdHex, peer] of peers.entries()) { - try { - const stats = await peer.pc.getStats(); - const prevStats = peerStatsRef.current.get(peerIdHex); - const currentStats: WebRTCStats = { - audio: { bytesReceived: 0, jitter: 0, packetsLost: 0, bitrate: 0 }, - video: { bytesReceived: 0, frameWidth: 0, frameHeight: 0, framesPerSecond: 0, bitrate: 0 }, - timestamp: Date.now() - }; - - stats.forEach(report => { - if (report.type === 'inbound-rtp') { - if (report.kind === 'audio') { - currentStats.audio.bytesReceived = report.bytesReceived || 0; - currentStats.audio.jitter = report.jitter || 0; - currentStats.audio.packetsLost = report.packetsLost || 0; - - if (prevStats) { - const deltaBytes = currentStats.audio.bytesReceived - prevStats.audio.bytesReceived; - const deltaTime = (currentStats.timestamp - prevStats.timestamp) / 1000; - if (deltaTime > 0) { - currentStats.audio.bitrate = Math.max(0, deltaBytes * 8 / deltaTime); - } - } - } else if (report.kind === 'video') { - currentStats.video.bytesReceived = report.bytesReceived || 0; - currentStats.video.frameWidth = report.frameWidth || 0; - currentStats.video.frameHeight = report.frameHeight || 0; - currentStats.video.framesPerSecond = report.framesPerSecond || 0; - - if (prevStats) { - const deltaBytes = currentStats.video.bytesReceived - prevStats.video.bytesReceived; - const deltaTime = (currentStats.timestamp - prevStats.timestamp) / 1000; - if (deltaTime > 0) { - currentStats.video.bitrate = Math.max(0, deltaBytes * 8 / deltaTime); - } - } - } - } - }); - - newStats.set(peerIdHex, currentStats); - } catch (e) { - console.warn(`[WebRTC] Failed to get stats for ${peerIdHex}`, e); - } - } - - peerStatsRef.current = newStats; - setPeerStats(newStats); - }, 2000); - - return () => clearInterval(interval); - }, [peers]); - - const toggleMute = useCallback(() => setIsMuted(prev => !prev), []); - const toggleDeafen = useCallback(() => setIsDeafened(prev => !prev), []); - - const closePeer = useCallback((peerIdHex: string) => { - const peer = peersRef.current.get(peerIdHex); - if (peer) { - peer.pc.close(); - if (peer.audio) { - peer.audio.pause(); - peer.audio.srcObject = null; - } - peersRef.current.delete(peerIdHex); - setPeers(new Map(peersRef.current)); - setPeerStatuses(prev => { - const next = new Map(prev); - next.delete(peerIdHex); - return next; - }); - } - }, []); - - const startWatching = useCallback((peerIdentity: Identity) => { - if (connectedChannelId) { - startWatchingReducer({ watchee: peerIdentity, channelId: connectedChannelId }); - } - }, [connectedChannelId, startWatchingReducer]); - - const stopWatching = useCallback((peerIdentity: Identity) => { - stopWatchingReducer({ watchee: peerIdentity }); - }, [stopWatchingReducer]); - - const startScreenShare = useCallback(async () => { - console.log("[WebRTC] startScreenShare called"); - try { - const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }); - console.log("[WebRTC] getDisplayMedia success"); - setLocalScreenStream(stream); - localScreenStreamRef.current = stream; - setSharingScreen({ sharing: true }); - - const tracks = stream.getTracks(); - const videoTrack = stream.getVideoTracks()[0]; - console.log(`[WebRTC] Sharing screen tracks: ${tracks.map(t => t.kind).join(', ')}`); - - const peers = Array.from(peersRef.current.entries()); - console.log(`[WebRTC] Sending screen tracks to ${peers.length} peers`); - - for (const [peerIdHex, peer] of peers) { - // Remove any old video tracks before adding the new screen video track - peer.pc.getSenders().forEach(sender => { - if (sender.track?.kind === 'video') { - console.log(`[WebRTC] Removing old video track sender for ${peerIdHex}`); - peer.pc.removeTrack(sender); - } - }); - - tracks.forEach(track => { - console.log(`[WebRTC] Adding screen ${track.kind} track to PC for ${peerIdHex}`); - peer.pc.addTrack(track, stream); - }); - - const offer = await peer.pc.createOffer(); - await peer.pc.setLocalDescription(offer); - console.log(`[WebRTC] Sent new offer with screen tracks to ${peerIdHex}`); - sendSdpOffer({ - receiver: Identity.fromString(peerIdHex), - sdp: JSON.stringify(offer), - channelId: connectedChannelId! - }); - } - - if (videoTrack) { - videoTrack.onended = () => { - console.log("[WebRTC] Screen share track ended"); - stopScreenShare(); - }; - } - } catch (err) { - console.error("[WebRTC] Failed to start screen share:", err); - } - }, [connectedChannelId, sendSdpOffer, setSharingScreen]); - - const stopScreenShare = useCallback(() => { - if (localScreenStreamRef.current) { - const screenTracks = localScreenStreamRef.current.getTracks(); - screenTracks.forEach(track => track.stop()); - setLocalScreenStream(null); - localScreenStreamRef.current = null; - setSharingScreen({ sharing: false }); - - peersRef.current.forEach(async (peer, peerIdHex) => { - peer.pc.getSenders().forEach(sender => { - if (sender.track && screenTracks.includes(sender.track)) { - console.log(`[WebRTC] Removing screen ${sender.track.kind} track from ${peerIdHex}`); - peer.pc.removeTrack(sender); - } - }); - const offer = await peer.pc.createOffer(); - await peer.pc.setLocalDescription(offer); - sendSdpOffer({ - receiver: Identity.fromString(peerIdHex), - sdp: JSON.stringify(offer), - channelId: connectedChannelId! - }); - }); - } - }, [setSharingScreen, sendSdpOffer, connectedChannelId]); - - useEffect(() => { - if (!connectedChannelId || !identity) return; - const currentPeers = Array.from(peersRef.current.keys()); - currentPeers.forEach(peerIdHex => { - const isStillInChannel = voiceStates.some(vs => - vs.channelId === connectedChannelId && vs.identity.toHexString() === peerIdHex - ); - if (!isStillInChannel) { - console.log(`[WebRTC] Peer ${peerIdHex} left channel, closing connection`); - closePeer(peerIdHex); - } - }); - }, [voiceStates, connectedChannelId, identity, closePeer]); - - useEffect(() => { - if (!localStream) { - if (isTalkingRef.current) { - setTalking({ talking: false }); - isTalkingRef.current = false; - } - return; - } - const audioContext = new AudioContext(); - const analyser = audioContext.createAnalyser(); - const source = audioContext.createMediaStreamSource(localStream); - analyser.fftSize = 256; - source.connect(analyser); - const dataArray = new Uint8Array(analyser.frequencyBinCount); - const threshold = 15; - let silenceFrames = 0; - const maxSilenceFrames = 10; - const checkAudio = setInterval(() => { - analyser.getByteFrequencyData(dataArray); - let sum = 0; - for (let i = 0; i < dataArray.length; i++) sum += dataArray[i]; - const average = sum / dataArray.length; - if (average > threshold) { - silenceFrames = 0; - if (!isTalkingRef.current) { - setTalking({ talking: true }); - isTalkingRef.current = true; - } - } else { - silenceFrames++; - if (silenceFrames > maxSilenceFrames && isTalkingRef.current) { - setTalking({ talking: false }); - isTalkingRef.current = false; - } - } - }, 50); - return () => { - clearInterval(checkAudio); - audioContext.close(); - }; - }, [localStream, setTalking]); - - useEffect(() => { - if (connectedChannelId) { - if (!localStream) { - console.log("[WebRTC] Joining voice channel, requesting mic permission..."); - navigator.mediaDevices.getUserMedia({ audio: true, video: false }) - .then(stream => { - console.log("[WebRTC] Mic permission granted"); - setLocalStream(stream); - localStreamRef.current = stream; - }) - .catch(err => console.error("[WebRTC] Failed to get mic:", err)); - } - } else { - if (localStream) { - console.log("[WebRTC] Leaving voice channel, stopping tracks"); - localStream.getTracks().forEach(track => track.stop()); - setLocalStream(null); - localStreamRef.current = null; - } - if (localScreenStreamRef.current) { - localScreenStreamRef.current.getTracks().forEach(track => track.stop()); - setLocalScreenStream(null); - localScreenStreamRef.current = null; - } - peersRef.current.forEach(peer => { - peer.pc.close(); - if (peer.audio) { - peer.audio.pause(); - peer.audio.srcObject = null; - } - }); - peersRef.current.clear(); - setPeers(new Map()); - setPeerStatuses(new Map()); - } - }, [connectedChannelId]); - - const createPeerConnection = useCallback((peerIdentity: Identity) => { - const peerIdHex = peerIdentity.toHexString(); - if (peersRef.current.has(peerIdHex)) return peersRef.current.get(peerIdHex)!.pc; - - console.log(`[WebRTC] Creating new PeerConnection for ${peerIdHex}`); - const pc = new RTCPeerConnection(ICE_SERVERS); - - pc.onnegotiationneeded = async () => { - try { - if (identity && identity.toHexString() > peerIdHex) { - console.log(`[WebRTC] Negotiation needed for ${peerIdHex}, sending offer`); - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - sendSdpOffer({ - receiver: peerIdentity, - sdp: JSON.stringify(offer), - channelId: connectedChannelId! - }); - } - } catch (e) { - console.error(`[WebRTC] Error during negotiation for ${peerIdHex}`, e); - } - }; - - pc.onicecandidate = (event) => { - if (event.candidate && connectedChannelId) { - sendIceCandidate({ receiver: peerIdentity, candidate: JSON.stringify(event.candidate), channelId: connectedChannelId }); - } - }; - pc.oniceconnectionstatechange = () => { - console.log(`[WebRTC] ICE state for ${peerIdHex}: ${pc.iceConnectionState}`); - setPeerStatuses(prev => { - const next = new Map(prev); - next.set(peerIdHex, pc.iceConnectionState); - return next; - }); - }; - pc.ontrack = (event) => { - console.log(`[WebRTC] Received track from ${peerIdHex}: ${event.track.kind} (Streams: ${event.streams.length})`); - const stream = event.streams[0] || new MediaStream([event.track]); - - setPeers(prev => { - const next = new Map(prev); - const existingPeer = next.get(peerIdHex) || { pc }; - - if (event.track.kind === 'audio') { - if (!existingPeer.audio) { - existingPeer.audio = new Audio(); - existingPeer.audio.autoplay = true; - } - existingPeer.audio.srcObject = stream; - existingPeer.audio.muted = isDeafened; - existingPeer.audio.play().catch(e => console.error("[WebRTC] Error playing audio", e)); - } else if (event.track.kind === 'video') { - console.log(`[WebRTC] Setting videoStream for ${peerIdHex}`); - existingPeer.videoStream = stream; - } - next.set(peerIdHex, existingPeer); - peersRef.current = next; - return next; - }); - }; - - // Add existing tracks - if (localStreamRef.current) { - console.log(`[WebRTC] Adding local audio tracks to new PC for ${peerIdHex}`); - localStreamRef.current.getTracks().forEach(t => pc.addTrack(t, localStreamRef.current!)); - } - if (localScreenStreamRef.current) { - console.log(`[WebRTC] Adding local screen tracks to new PC for ${peerIdHex}`); - localScreenStreamRef.current.getTracks().forEach(t => pc.addTrack(t, localScreenStreamRef.current!)); - } - - peersRef.current.set(peerIdHex, { pc }); - setPeers(new Map(peersRef.current)); - return pc; - }, [connectedChannelId, sendIceCandidate, isDeafened, identity, sendSdpOffer]); - - // Handle localStream changes (mic) - useEffect(() => { - if (localStream) { - const audioTrack = localStream.getAudioTracks()[0]; - if (audioTrack) { - peersRef.current.forEach((peer, peerIdHex) => { - const alreadyHasTrack = peer.pc.getSenders().some(s => s.track === audioTrack); - if (!alreadyHasTrack) { - console.log(`[WebRTC] Adding audio track to existing PC for ${peerIdHex}`); - peer.pc.addTrack(audioTrack, localStream); - // onnegotiationneeded will fire if we are the initiator - } - }); - } - } - }, [localStream]); - - useEffect(() => { - if (!connectedChannelId || !identity) return; - const channelPeers = voiceStates.filter(vs => vs.channelId === connectedChannelId && !vs.identity.isEqual(identity)); - channelPeers.forEach(async (peerVs) => { - const peerIdHex = peerVs.identity.toHexString(); - if (!peersRef.current.has(peerIdHex)) { - if (identity.toHexString() > peerIdHex) { - console.log(`[WebRTC] Initiating offer to ${peerIdHex}`); - const pc = createPeerConnection(peerVs.identity); - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - sendSdpOffer({ receiver: peerVs.identity, sdp: JSON.stringify(offer), channelId: connectedChannelId }); - } - } - }); - }, [voiceStates, connectedChannelId, identity, createPeerConnection, sendSdpOffer]); - - useEffect(() => { - if (!connectedChannelId || !identity) return; - const myOffers = offers.filter(o => o.receiver.isEqual(identity) && o.channelId === connectedChannelId); - myOffers.forEach(async (offerRow) => { - const pc = createPeerConnection(offerRow.sender); - if (pc.signalingState === "stable" || pc.signalingState === "have-local-offer") { - try { - console.log(`[WebRTC] Handling offer from ${offerRow.sender.toHexString()}`); - const offer = JSON.parse(offerRow.sdp); - await pc.setRemoteDescription(new RTCSessionDescription(offer)); - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - sendSdpAnswer({ receiver: offerRow.sender, sdp: JSON.stringify(answer), channelId: connectedChannelId }); - } catch (e) { console.error("[WebRTC] Error handling offer", e); } - } - }); - }, [offers, connectedChannelId, identity, createPeerConnection, sendSdpAnswer]); - - useEffect(() => { - if (!connectedChannelId || !identity) return; - const myAnswers = answers.filter(a => a.receiver.isEqual(identity) && a.channelId === connectedChannelId); - myAnswers.forEach(async (answerRow) => { - const peer = peersRef.current.get(answerRow.sender.toHexString()); - if (peer && peer.pc.signalingState === "have-local-offer") { - try { - const answer = JSON.parse(answerRow.sdp); - await peer.pc.setRemoteDescription(new RTCSessionDescription(answer)); - } catch (e) { console.error("[WebRTC] Error handling answer", e); } - } - }); - }, [answers, connectedChannelId, identity]); - - useEffect(() => { - if (!connectedChannelId || !identity) return; - const myCandidates = iceCandidates.filter(c => c.receiver.isEqual(identity) && c.channelId === connectedChannelId); - myCandidates.forEach(async (candRow) => { - const peer = peersRef.current.get(candRow.sender.toHexString()); - if (peer && peer.pc.remoteDescription) { - try { - const candidate = JSON.parse(candRow.candidate); - await peer.pc.addIceCandidate(new RTCIceCandidate(candidate)); - } catch (e) { console.error("[WebRTC] Error handling ICE", e); } - } - }); - }, [iceCandidates, connectedChannelId, identity]); - - return { - localStream, - localScreenStream, - peerStatuses, - peers, - startScreenShare, - stopScreenShare, - isSharingScreen: !!localScreenStream, - startWatching, - stopWatching, - watching, - isMuted, - isDeafened, - toggleMute, - toggleDeafen, - peerStats - }; -}; - -export default useWebRTC;