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:
parent
c9378f4cdb
commit
3f9b446f27
4 changed files with 4896 additions and 4851 deletions
4830
web/dist/assets/index-BW2laH1p.js
vendored
Normal file
4830
web/dist/assets/index-BW2laH1p.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4830
web/dist/assets/index-DD6kbsyw.js
vendored
4830
web/dist/assets/index-DD6kbsyw.js
vendored
File diff suppressed because one or more lines are too long
2
web/dist/index.html
vendored
2
web/dist/index.html
vendored
|
|
@ -5,7 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Gaming Hub</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>" />
|
||||
<script type="module" crossorigin src="/assets/index-DD6kbsyw.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BW2laH1p.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CcoMcI3c.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue