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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Gaming Hub</title>
|
<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>" />
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-CcoMcI3c.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
const peerConnectionsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
const peerConnectionsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
||||||
/** Viewer: single PeerConnection to broadcaster */
|
/** Viewer: single PeerConnection to broadcaster */
|
||||||
const viewerPcRef = useRef<RTCPeerConnection | null>(null);
|
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 reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const reconnectDelayRef = useRef(1000);
|
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 ──
|
// ── Viewer cleanup ──
|
||||||
const cleanupViewer = useCallback(() => {
|
const cleanupViewer = useCallback(() => {
|
||||||
if (viewerPcRef.current) {
|
if (viewerPcRef.current) {
|
||||||
|
|
@ -112,6 +138,7 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
viewerPcRef.current = null;
|
viewerPcRef.current = null;
|
||||||
}
|
}
|
||||||
if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null;
|
if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null;
|
||||||
|
pendingCandidatesRef.current.clear();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── WS message handler (uses refs, never stale) ──
|
// ── WS message handler (uses refs, never stale) ──
|
||||||
|
|
@ -144,6 +171,15 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
// ── Broadcaster: viewer joined → create offer ──
|
// ── Broadcaster: viewer joined → create offer ──
|
||||||
case 'viewer_joined': {
|
case 'viewer_joined': {
|
||||||
const viewerId = msg.viewerId;
|
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);
|
const pc = new RTCPeerConnection(RTC_CONFIG);
|
||||||
peerConnectionsRef.current.set(viewerId, pc);
|
peerConnectionsRef.current.set(viewerId, pc);
|
||||||
|
|
||||||
|
|
@ -161,16 +197,7 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.onnegotiationneeded = () => {
|
// Single offer (no onnegotiationneeded — tracks already added above)
|
||||||
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
|
|
||||||
pc.createOffer()
|
pc.createOffer()
|
||||||
.then(offer => pc.setLocalDescription(offer))
|
.then(offer => pc.setLocalDescription(offer))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|
@ -187,11 +214,21 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
pc.close();
|
pc.close();
|
||||||
peerConnectionsRef.current.delete(msg.viewerId);
|
peerConnectionsRef.current.delete(msg.viewerId);
|
||||||
}
|
}
|
||||||
|
pendingCandidatesRef.current.delete(msg.viewerId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Viewer: received offer from broadcaster ──
|
// ── Viewer: received offer from broadcaster ──
|
||||||
case 'offer': {
|
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);
|
const pc = new RTCPeerConnection(RTC_CONFIG);
|
||||||
viewerPcRef.current = pc;
|
viewerPcRef.current = pc;
|
||||||
|
|
||||||
|
|
@ -204,7 +241,7 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
|
|
||||||
pc.onicecandidate = (ev) => {
|
pc.onicecandidate = (ev) => {
|
||||||
if (ev.candidate) {
|
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))
|
pc.setRemoteDescription(new RTCSessionDescription(msg.sdp))
|
||||||
.then(() => pc.createAnswer())
|
.then(() => {
|
||||||
|
flushCandidates(pc, broadcasterId);
|
||||||
|
return pc.createAnswer();
|
||||||
|
})
|
||||||
.then(answer => pc.setLocalDescription(answer))
|
.then(answer => pc.setLocalDescription(answer))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
wsSend({ type: 'answer', targetId: msg.fromId, sdp: pc.localDescription });
|
wsSend({ type: 'answer', targetId: broadcasterId, sdp: pc.localDescription });
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
break;
|
break;
|
||||||
|
|
@ -228,18 +268,22 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
case 'answer': {
|
case 'answer': {
|
||||||
const pc = peerConnectionsRef.current.get(msg.fromId);
|
const pc = peerConnectionsRef.current.get(msg.fromId);
|
||||||
if (pc) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ICE candidate relay (uses ref, not stale state!) ──
|
// ── ICE candidate relay (queued until remote desc is set) ──
|
||||||
case 'ice_candidate': {
|
case 'ice_candidate': {
|
||||||
const pc = isBroadcastingRef.current
|
if (!msg.candidate) break;
|
||||||
? peerConnectionsRef.current.get(msg.fromId)
|
if (isBroadcastingRef.current) {
|
||||||
: viewerPcRef.current;
|
const pc = peerConnectionsRef.current.get(msg.fromId);
|
||||||
if (pc && msg.candidate) {
|
if (pc) addOrQueueCandidate(pc, msg.fromId, msg.candidate);
|
||||||
pc.addIceCandidate(new RTCIceCandidate(msg.candidate)).catch(() => {});
|
} else {
|
||||||
|
const pc = viewerPcRef.current;
|
||||||
|
if (pc) addOrQueueCandidate(pc, msg.fromId, msg.candidate);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -352,6 +396,7 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
|
|
||||||
for (const pc of peerConnectionsRef.current.values()) pc.close();
|
for (const pc of peerConnectionsRef.current.values()) pc.close();
|
||||||
peerConnectionsRef.current.clear();
|
peerConnectionsRef.current.clear();
|
||||||
|
pendingCandidatesRef.current.clear();
|
||||||
|
|
||||||
setIsBroadcasting(false);
|
setIsBroadcasting(false);
|
||||||
isBroadcastingRef.current = false;
|
isBroadcastingRef.current = false;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue