Files
zep/src/chat/services/webrtc/useScreenSharingWebRTC.ts
T
2026-03-30 17:41:54 -04:00

236 lines
10 KiB
TypeScript

import { useEffect, useCallback, useMemo, useRef } from "react";
import { Identity } from "spacetimedb";
import { useTable, useReducer } from "spacetimedb/react";
import { tables, reducers } from "../../../module_bindings";
import { usePeerManager } from "./usePeerManager";
export const useScreenSharingWebRTC = (
connectedChannelId: bigint | undefined,
identity: Identity | null,
localScreenStream: MediaStream | null
) => {
const [watching] = useTable(tables.watching);
const [offers] = useTable(tables.screen_sdp_offer);
const [answers] = useTable(tables.screen_sdp_answer);
const [iceCandidates] = useTable(tables.screen_ice_candidate);
const sendSdpOffer = useReducer(reducers.sendScreenSdpOffer);
const sendSdpAnswer = useReducer(reducers.sendScreenSdpAnswer);
const sendIceCandidate = useReducer(reducers.sendScreenIceCandidate);
const makingOfferRef = useRef<Map<string, boolean>>(new Map());
const ignoreOfferRef = useRef<Map<string, boolean>>(new Map());
const processedOffersRef = useRef<Set<bigint>>(new Set());
const processedAnswersRef = useRef<Set<bigint>>(new Set());
const processedCandidatesRef = useRef<Set<bigint>>(new Set());
const candidateQueueRef = useRef<Map<string, any[]>>(new Map());
const connectedChannelIdRef = useRef(connectedChannelId);
useEffect(() => { connectedChannelIdRef.current = connectedChannelId; }, [connectedChannelId]);
const drainCandidateQueue = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => {
const queue = candidateQueueRef.current.get(peerIdHex) || [];
if (queue.length === 0 || !pc.remoteDescription) return;
console.log(`[WebRTC][screen] Draining ${queue.length} candidates for ${peerIdHex}`);
for (const cand of queue) {
try { await pc.addIceCandidate(new RTCIceCandidate(cand)); }
catch (e) { console.warn(`[WebRTC][screen] Error adding queued ICE for ${peerIdHex}`, e); }
}
candidateQueueRef.current.set(peerIdHex, []);
}, []);
const onNegotiationNeeded = useCallback(async (peerIdHex: string, pc: RTCPeerConnection) => {
const channelId = connectedChannelIdRef.current;
if (!channelId || pc.signalingState !== 'stable' || makingOfferRef.current.get(peerIdHex)) {
console.log(`[WebRTC][screen] Skipping negotiation for ${peerIdHex}: state=${pc.signalingState}, makingOffer=${makingOfferRef.current.get(peerIdHex)}`);
return;
}
try {
makingOfferRef.current.set(peerIdHex, true);
console.log(`[WebRTC][screen] Creating offer for ${peerIdHex}...`);
await pc.setLocalDescription();
sendSdpOffer({
receiver: Identity.fromString(peerIdHex),
sdp: JSON.stringify(pc.localDescription),
channelId
});
} catch (e) { console.error(`[WebRTC][screen] Negotiation error 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 peerManager = usePeerManager(
identity,
"screen",
false,
onNegotiationNeeded,
onIceCandidate
);
// Signaling
useEffect(() => {
if (!connectedChannelId || !identity) return;
const myOffers = offers.filter(o => o.receiver.isEqual(identity) && !o.sender.isEqual(identity) && o.channelId === connectedChannelId);
(async () => {
for (const offerRow of myOffers) {
if (processedOffersRef.current.has(offerRow.id)) continue;
processedOffersRef.current.add(offerRow.id);
const peerIdHex = offerRow.sender.toHexString();
console.log(`[WebRTC][screen] Received offer from ${peerIdHex}`);
const pc = peerManager.createPeerConnection(peerIdHex);
if (!pc) continue;
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][screen] Ignoring offer collision from ${peerIdHex} (impolite)`);
continue;
}
if (offerCollision) {
console.log(`[WebRTC][screen] Handling offer collision from ${peerIdHex} (polite), rolling back...`);
await pc.setLocalDescription({ type: "rollback" as RTCSdpType });
}
console.log(`[WebRTC][screen] Setting remote description from ${peerIdHex}`);
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(offerRow.sdp)));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
console.log(`[WebRTC][screen] Sending answer to ${peerIdHex}`);
sendSdpAnswer({ receiver: offerRow.sender, sdp: JSON.stringify(answer), channelId: connectedChannelId });
await drainCandidateQueue(peerIdHex, pc);
} catch (e) { console.error(`[WebRTC][screen] Error handling offer from ${peerIdHex}`, e); }
}
})();
const myAnswers = answers.filter(a => a.receiver.isEqual(identity) && !a.sender.isEqual(identity) && a.channelId === connectedChannelId);
(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 = peerManager.getPeer(peerIdHex);
if (peer) {
try {
console.log(`[WebRTC][screen] Received answer from ${peerIdHex}, setting remote description`);
await peer.pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(answerRow.sdp)));
await drainCandidateQueue(peerIdHex, peer.pc);
} catch (e) { console.error(`[WebRTC][screen] Error handling answer from ${peerIdHex}`, e); }
}
}
})();
const myCandidates = iceCandidates.filter(c => c.receiver.isEqual(identity) && !c.sender.isEqual(identity) && c.channelId === connectedChannelId);
(async () => {
for (const candRow of myCandidates) {
if (processedCandidatesRef.current.has(candRow.id)) continue;
processedCandidatesRef.current.add(candRow.id);
const peerIdHex = candRow.sender.toHexString();
const pc = peerManager.createPeerConnection(peerIdHex);
if (!pc) continue;
try {
const candidate = JSON.parse(candRow.candidate);
const ignoreOffer = ignoreOfferRef.current.get(peerIdHex) || false;
if (pc.remoteDescription) {
console.log(`[WebRTC][screen] Adding ICE candidate from ${peerIdHex}`);
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} else if (!ignoreOffer) {
console.log(`[WebRTC][screen] Queueing ICE candidate from ${peerIdHex}`);
const queue = candidateQueueRef.current.get(peerIdHex) || [];
queue.push(candidate);
candidateQueueRef.current.set(peerIdHex, queue);
}
} catch (e) { console.error(`[WebRTC][screen] Error handling ICE from ${peerIdHex}`, e); }
}
})();
}, [offers, answers, iceCandidates, connectedChannelId, identity, peerManager, sendSdpAnswer, drainCandidateQueue]);
// Track Syncing
useEffect(() => {
const videoTrack = localScreenStream?.getVideoTracks()[0] || null;
const audioTrack = localScreenStream?.getAudioTracks()[0] || null;
peerManager.peersRef.current.forEach(async (peer, peerIdHex) => {
const transceivers = peer.pc.getTransceivers();
let changed = false;
if (transceivers[0] && transceivers[0].sender.track !== videoTrack) {
await transceivers[0].sender.replaceTrack(videoTrack);
changed = true;
}
if (transceivers[1] && transceivers[1].sender.track !== audioTrack) {
await transceivers[1].sender.replaceTrack(audioTrack);
changed = true;
}
if (changed && peer.pc.signalingState === 'stable') {
console.log(`[WebRTC][screen] Syncing track for ${peerIdHex}`);
onNegotiationNeeded(peerIdHex, peer.pc);
}
});
}, [localScreenStream, peerManager.peers, onNegotiationNeeded, peerManager.peersRef]);
// Lifecycle
const screenPeersToConnect = useMemo(() => {
if (!identity || !connectedChannelId) return new Set<string>();
const peerIds = new Set<string>();
watching.forEach(w => {
if (w.channelId === connectedChannelId) {
if (w.watcher.isEqual(identity)) peerIds.add(w.watchee.toHexString());
else if (w.watchee.isEqual(identity)) peerIds.add(w.watcher.toHexString());
}
});
return peerIds;
}, [watching, identity, connectedChannelId]);
useEffect(() => {
if (!connectedChannelId || !identity) {
console.log(`[WebRTC][screen] Cleaning up connections (channel=${connectedChannelId}, identity=${!!identity})`);
peerManager.peersRef.current.forEach((_, id) => peerManager.closePeer(id));
processedOffersRef.current.clear();
processedAnswersRef.current.clear();
processedCandidatesRef.current.clear();
return;
}
screenPeersToConnect.forEach(id => {
if (!peerManager.peersRef.current.has(id)) {
console.log(`[WebRTC][screen] Connecting to watched peer ${id}`);
peerManager.createPeerConnection(id, [
localScreenStream?.getVideoTracks()[0] || null,
localScreenStream?.getAudioTracks()[0] || null
]);
}
});
peerManager.peersRef.current.forEach((_, id) => {
if (!screenPeersToConnect.has(id)) {
console.log(`[WebRTC][screen] Peer ${id} no longer watched, closing`);
peerManager.closePeer(id);
}
});
}, [screenPeersToConnect, connectedChannelId, identity, peerManager, localScreenStream]);
return {
peers: peerManager.peers
};
};