Files
zep/src/useWebRTC.ts
T
2026-03-28 22:05:24 -04:00

206 lines
6.9 KiB
TypeScript

import { useEffect, useRef, useState, useCallback } from 'react';
import { Identity } from 'spacetimedb';
import { tables } from './module_bindings';
import { useTable } from 'spacetimedb/react';
const [offers] = useTable(tables.sdp_offer);
const [answers] = useTable(tables.sdp_answer);
const [candidates] = useTable(tables.ice_candidate);
export type PeerConnection = {
identity: Identity;
connection: RTCPeerConnection;
stream?: MediaStream;
connectionState: RTCPeerConnectionState;
iceConnectionState: RTCIceConnectionState;
};
export function useWebRTC(
identity: Identity | null,
currentChannelId: bigint | null,
voiceStates: any[]
) {
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [remotePeers, setRemotePeers] = useState<Map<string, PeerConnection>>(new Map());
const peersRef = useRef<Map<string, PeerConnection>>(new Map());
const localStreamRef = useRef<MediaStream | null>(null);
const iceConfig = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
],
};
const cleanupPeer = useCallback((peerId: string) => {
const peer = peersRef.current.get(peerId);
if (peer) {
peer.connection.close();
peersRef.current.delete(peerId);
setRemotePeers(new Map(peersRef.current));
}
}, []);
const createPeerConnection = useCallback((peerIdentity: Identity, isOfferer: boolean) => {
const peerId = peerIdentity.toHexString();
if (peersRef.current.has(peerId)) return peersRef.current.get(peerId)!.connection;
const pc = new RTCPeerConnection(iceConfig);
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => {
pc.addTrack(track, localStreamRef.current!);
});
}
pc.onicecandidate = (event) => {
if (event.candidate && currentChannelId) {
try {
sendIceCandidate({
receiver: peerIdentity,
candidate: JSON.stringify(event.candidate),
channelId: currentChannelId
});
} catch (e) {
console.error("Error sending ICE candidate:", e);
}
}
};
pc.ontrack = (event) => {
console.log("Received remote track from", peerId);
const peer = peersRef.current.get(peerId);
if (peer) {
peer.stream = event.streams[0];
setRemotePeers(new Map(peersRef.current));
}
};
pc.onconnectionstatechange = () => {
console.log(`Connection state change for ${peerId}: ${pc.connectionState}`);
const updatedPeer = peersRef.current.get(peerId);
if (updatedPeer) {
updatedPeer.connectionState = pc.connectionState;
setRemotePeers(new Map(peersRef.current));
}
};
pc.oniceconnectionstatechange = () => {
console.log(`ICE connection state change for ${peerId}: ${pc.iceConnectionState}`);
const updatedPeer = peersRef.current.get(peerId);
if (updatedPeer) {
updatedPeer.iceConnectionState = pc.iceConnectionState;
setRemotePeers(new Map(peersRef.current));
}
};
const peerObj: PeerConnection = { identity: peerIdentity, connection: pc, connectionState: pc.connectionState, iceConnectionState: pc.iceConnectionState };
peersRef.current.set(peerId, peerObj);
setRemotePeers(new Map(peersRef.current));
return pc;
}, [currentChannelId]);
useEffect(() => {
if (currentChannelId) {
navigator.mediaDevices.getUserMedia({ audio: true, video: false })
.then(stream => {
setLocalStream(stream);
localStreamRef.current = stream;
})
.catch(err => console.error("Error getting audio stream:", err));
} else {
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(t => t.stop());
localStreamRef.current = null;
setLocalStream(null);
}
peersRef.current.forEach((_, id) => cleanupPeer(id));
}
}, [currentChannelId, cleanupPeer]);
useEffect(() => {
if (!currentChannelId || !identity || !localStream) { // Ensure localStream is available before proceeding
return;
}
voiceStates.forEach(async (vs) => {
if (vs.channelId === currentChannelId && !vs.identity.isEqual(identity)) {
const peerId = vs.identity.toHexString();
if (!peersRef.current.has(peerId)) {
if (identity.toHexString() > peerId) { // Polite peer logic
const pc = createPeerConnection(vs.identity, true);
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
sendSdpOffer({
receiver: vs.identity,
sdp: JSON.stringify(offer),
channelId: currentChannelId
});
} catch (e) {
console.error("Error creating or sending offer:", e);
}
}
}
}
});
peersRef.current.forEach((_, id) => {
if (!voiceStates.some(vs => vs.identity.toHexString() === id && vs.channelId === currentChannelId)) {
cleanupPeer(id);
}
});
}, [voiceStates, currentChannelId, identity, localStream, createPeerConnection, cleanupPeer]);
useEffect(() => {
if (!currentChannelId || !identity || !localStream) { // Ensure localStream is available before proceeding
return;
}
offers.forEach(async (offerRow) => {
if (offerRow.receiver.isEqual(identity) && offerRow.channelId === currentChannelId) {
const peerId = offerRow.sender.toHexString();
if (!peersRef.current.has(peerId)) {
const pc = createPeerConnection(offerRow.sender, false);
try {
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: currentChannelId
});
} catch (e) {
console.error("Error creating or sending answer:", e);
}
}
}
});
}, [offers, currentChannelId, identity, localStream, createPeerConnection]);
useEffect(() => {
if (!currentChannelId || !identity) return;
candidates.forEach(async (candRow) => {
if (candRow.receiver.isEqual(identity) && candRow.channelId === currentChannelId) {
const peerId = candRow.sender.toHexString();
const peer = peersRef.current.get(peerId);
if (peer && peer.connection.remoteDescription) {
try {
const candidate = JSON.parse(candRow.candidate);
await peer.connection.addIceCandidate(new RTCIceCandidate(candidate));
} catch (e) {
console.error("Error adding ICE candidate:", e);
}
}
}
});
}, [candidates, currentChannelId, identity]);
return { localStream, remotePeers };
}