236 lines
10 KiB
TypeScript
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
|
|
};
|
|
};
|