Fix: Schwarzes Bild bei Viewern - WebRTC Race Conditions behoben

- Doppelter Offer entfernt (onnegotiationneeded + explizit createOffer)
- ICE Candidate Queuing: Candidates werden gepuffert bis setRemoteDescription
  fertig ist, statt sie zu verwerfen
- Cleanup bei Re-Offer: vorherige PeerConnection wird sauber geschlossen
- Pending Candidates werden bei Disconnect/Leave aufgeraeumt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-07 01:16:09 +01:00
parent c9378f4cdb
commit 3f9b446f27
4 changed files with 4896 additions and 4851 deletions

View file

@ -69,6 +69,8 @@ export default function StreamingTab({ data }: { data: any }) {
const peerConnectionsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
/** Viewer: single PeerConnection to broadcaster */
const viewerPcRef = useRef<RTCPeerConnection | null>(null);
/** ICE candidate queue — candidates that arrived before setRemoteDescription */
const pendingCandidatesRef = useRef<Map<string, RTCIceCandidateInit[]>>(new Map());
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectDelayRef = useRef(1000);
@ -105,6 +107,30 @@ export default function StreamingTab({ data }: { data: any }) {
}
}, []);
// ── ICE candidate queuing (prevents candidates arriving before remote desc) ──
const addOrQueueCandidate = useCallback((pc: RTCPeerConnection, peerId: string, candidate: RTCIceCandidateInit) => {
if (pc.remoteDescription) {
pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(() => {});
} else {
let queue = pendingCandidatesRef.current.get(peerId);
if (!queue) {
queue = [];
pendingCandidatesRef.current.set(peerId, queue);
}
queue.push(candidate);
}
}, []);
const flushCandidates = useCallback((pc: RTCPeerConnection, peerId: string) => {
const queue = pendingCandidatesRef.current.get(peerId);
if (queue) {
for (const c of queue) {
pc.addIceCandidate(new RTCIceCandidate(c)).catch(() => {});
}
pendingCandidatesRef.current.delete(peerId);
}
}, []);
// ── Viewer cleanup ──
const cleanupViewer = useCallback(() => {
if (viewerPcRef.current) {
@ -112,6 +138,7 @@ export default function StreamingTab({ data }: { data: any }) {
viewerPcRef.current = null;
}
if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null;
pendingCandidatesRef.current.clear();
}, []);
// ── WS message handler (uses refs, never stale) ──
@ -144,6 +171,15 @@ export default function StreamingTab({ data }: { data: any }) {
// ── Broadcaster: viewer joined → create offer ──
case 'viewer_joined': {
const viewerId = msg.viewerId;
// Clean up existing connection if viewer re-joins
const existingPc = peerConnectionsRef.current.get(viewerId);
if (existingPc) {
existingPc.close();
peerConnectionsRef.current.delete(viewerId);
}
pendingCandidatesRef.current.delete(viewerId);
const pc = new RTCPeerConnection(RTC_CONFIG);
peerConnectionsRef.current.set(viewerId, pc);
@ -161,16 +197,7 @@ export default function StreamingTab({ data }: { data: any }) {
}
};
pc.onnegotiationneeded = () => {
pc.createOffer()
.then(offer => pc.setLocalDescription(offer))
.then(() => {
wsSend({ type: 'offer', targetId: viewerId, sdp: pc.localDescription });
})
.catch(console.error);
};
// If negotiationneeded doesn't fire (tracks already added), create offer now
// Single offer (no onnegotiationneeded — tracks already added above)
pc.createOffer()
.then(offer => pc.setLocalDescription(offer))
.then(() => {
@ -187,11 +214,21 @@ export default function StreamingTab({ data }: { data: any }) {
pc.close();
peerConnectionsRef.current.delete(msg.viewerId);
}
pendingCandidatesRef.current.delete(msg.viewerId);
break;
}
// ── Viewer: received offer from broadcaster ──
case 'offer': {
const broadcasterId = msg.fromId;
// Close previous PC if exists (e.g. re-offer)
if (viewerPcRef.current) {
viewerPcRef.current.close();
viewerPcRef.current = null;
}
pendingCandidatesRef.current.delete(broadcasterId);
const pc = new RTCPeerConnection(RTC_CONFIG);
viewerPcRef.current = pc;
@ -204,7 +241,7 @@ export default function StreamingTab({ data }: { data: any }) {
pc.onicecandidate = (ev) => {
if (ev.candidate) {
wsSend({ type: 'ice_candidate', targetId: msg.fromId, candidate: ev.candidate.toJSON() });
wsSend({ type: 'ice_candidate', targetId: broadcasterId, candidate: ev.candidate.toJSON() });
}
};
@ -215,10 +252,13 @@ export default function StreamingTab({ data }: { data: any }) {
};
pc.setRemoteDescription(new RTCSessionDescription(msg.sdp))
.then(() => pc.createAnswer())
.then(() => {
flushCandidates(pc, broadcasterId);
return pc.createAnswer();
})
.then(answer => pc.setLocalDescription(answer))
.then(() => {
wsSend({ type: 'answer', targetId: msg.fromId, sdp: pc.localDescription });
wsSend({ type: 'answer', targetId: broadcasterId, sdp: pc.localDescription });
})
.catch(console.error);
break;
@ -228,18 +268,22 @@ export default function StreamingTab({ data }: { data: any }) {
case 'answer': {
const pc = peerConnectionsRef.current.get(msg.fromId);
if (pc) {
pc.setRemoteDescription(new RTCSessionDescription(msg.sdp)).catch(console.error);
pc.setRemoteDescription(new RTCSessionDescription(msg.sdp))
.then(() => flushCandidates(pc, msg.fromId))
.catch(console.error);
}
break;
}
// ── ICE candidate relay (uses ref, not stale state!) ──
// ── ICE candidate relay (queued until remote desc is set) ──
case 'ice_candidate': {
const pc = isBroadcastingRef.current
? peerConnectionsRef.current.get(msg.fromId)
: viewerPcRef.current;
if (pc && msg.candidate) {
pc.addIceCandidate(new RTCIceCandidate(msg.candidate)).catch(() => {});
if (!msg.candidate) break;
if (isBroadcastingRef.current) {
const pc = peerConnectionsRef.current.get(msg.fromId);
if (pc) addOrQueueCandidate(pc, msg.fromId, msg.candidate);
} else {
const pc = viewerPcRef.current;
if (pc) addOrQueueCandidate(pc, msg.fromId, msg.candidate);
}
break;
}
@ -352,6 +396,7 @@ export default function StreamingTab({ data }: { data: any }) {
for (const pc of peerConnectionsRef.current.values()) pc.close();
peerConnectionsRef.current.clear();
pendingCandidatesRef.current.clear();
setIsBroadcasting(false);
isBroadcastingRef.current = false;