fix(streaming): resolve stale closure preventing remote WebRTC connections
The WebSocket onmessage handler captured isBroadcasting=false at creation time. When ICE candidates arrived from remote viewers, the handler looked up viewerPcRef instead of peerConnectionsRef, dropping all candidates. Fix: use refs (isBroadcastingRef, viewingRef, handleWsMessageRef) so the WS handler always reads current state. connectWs() now has [] deps and delegates to handleWsMessageRef.current for every message. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
29bcf67121
commit
dacfde4328
1 changed files with 78 additions and 61 deletions
|
|
@ -61,6 +61,12 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const reconnectDelayRef = useRef(1000);
|
const reconnectDelayRef = useRef(1000);
|
||||||
|
|
||||||
|
// ── Refs that mirror state (to avoid stale closures in WS handler) ──
|
||||||
|
const isBroadcastingRef = useRef(false);
|
||||||
|
const viewingRef = useRef<ViewState | null>(null);
|
||||||
|
useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]);
|
||||||
|
useEffect(() => { viewingRef.current = viewing; }, [viewing]);
|
||||||
|
|
||||||
// ── Elapsed time ticker ──
|
// ── Elapsed time ticker ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasActive = streams.length > 0 || isBroadcasting;
|
const hasActive = streams.length > 0 || isBroadcasting;
|
||||||
|
|
@ -81,42 +87,25 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
if (userName) localStorage.setItem('streaming_name', userName);
|
if (userName) localStorage.setItem('streaming_name', userName);
|
||||||
}, [userName]);
|
}, [userName]);
|
||||||
|
|
||||||
// ── WebSocket connect ──
|
// ── Send via WS ──
|
||||||
const connectWs = useCallback(() => {
|
const wsSend = useCallback((d: Record<string, any>) => {
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return;
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify(d));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
// ── Viewer cleanup ──
|
||||||
const ws = new WebSocket(`${proto}://${location.host}/ws/streaming`);
|
const cleanupViewer = useCallback(() => {
|
||||||
wsRef.current = ws;
|
if (viewerPcRef.current) {
|
||||||
|
viewerPcRef.current.close();
|
||||||
|
viewerPcRef.current = null;
|
||||||
|
}
|
||||||
|
if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
ws.onopen = () => {
|
// ── WS message handler (uses refs, never stale) ──
|
||||||
reconnectDelayRef.current = 1000;
|
const handleWsMessageRef = useRef<(msg: any) => void>(() => {});
|
||||||
};
|
handleWsMessageRef.current = (msg: any) => {
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
let msg: any;
|
|
||||||
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
||||||
handleWsMessage(msg);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
wsRef.current = null;
|
|
||||||
// Auto-reconnect if broadcasting or viewing
|
|
||||||
if (isBroadcasting || viewing) {
|
|
||||||
reconnectTimerRef.current = setTimeout(() => {
|
|
||||||
reconnectDelayRef.current = Math.min(reconnectDelayRef.current * 2, 10000);
|
|
||||||
connectWs();
|
|
||||||
}, reconnectDelayRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
ws.close();
|
|
||||||
};
|
|
||||||
}, [isBroadcasting, viewing]);
|
|
||||||
|
|
||||||
// ── WS message handler ──
|
|
||||||
const handleWsMessage = useCallback((msg: any) => {
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'welcome':
|
case 'welcome':
|
||||||
clientIdRef.current = msg.clientId;
|
clientIdRef.current = msg.clientId;
|
||||||
|
|
@ -126,15 +115,16 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
case 'broadcast_started':
|
case 'broadcast_started':
|
||||||
setMyStreamId(msg.streamId);
|
setMyStreamId(msg.streamId);
|
||||||
setIsBroadcasting(true);
|
setIsBroadcasting(true);
|
||||||
|
isBroadcastingRef.current = true; // immediate update for handler
|
||||||
setStarting(false);
|
setStarting(false);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'stream_available':
|
case 'stream_available':
|
||||||
// SSE will update streams list; this is just a hint
|
// SSE will update streams list
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'stream_ended':
|
case 'stream_ended':
|
||||||
if (viewing?.streamId === msg.streamId) {
|
if (viewingRef.current?.streamId === msg.streamId) {
|
||||||
cleanupViewer();
|
cleanupViewer();
|
||||||
setViewing(null);
|
setViewing(null);
|
||||||
}
|
}
|
||||||
|
|
@ -160,6 +150,16 @@ 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
|
||||||
pc.createOffer()
|
pc.createOffer()
|
||||||
.then(offer => pc.setLocalDescription(offer))
|
.then(offer => pc.setLocalDescription(offer))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|
@ -222,9 +222,9 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ICE candidate relay ──
|
// ── ICE candidate relay (uses ref, not stale state!) ──
|
||||||
case 'ice_candidate': {
|
case 'ice_candidate': {
|
||||||
const pc = isBroadcasting
|
const pc = isBroadcastingRef.current
|
||||||
? peerConnectionsRef.current.get(msg.fromId)
|
? peerConnectionsRef.current.get(msg.fromId)
|
||||||
: viewerPcRef.current;
|
: viewerPcRef.current;
|
||||||
if (pc && msg.candidate) {
|
if (pc && msg.candidate) {
|
||||||
|
|
@ -238,20 +238,47 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
setStarting(false);
|
setStarting(false);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [isBroadcasting, viewing]);
|
|
||||||
|
|
||||||
// ── Send via WS ──
|
|
||||||
const wsSend = (data: Record<string, any>) => {
|
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
||||||
wsRef.current.send(JSON.stringify(data));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── WebSocket connect (stable, no state deps) ──
|
||||||
|
const connectWs = useCallback(() => {
|
||||||
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const ws = new WebSocket(`${proto}://${location.host}/ws/streaming`);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnectDelayRef.current = 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delegate to ref so handler is always current
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let msg: any;
|
||||||
|
try { msg = JSON.parse(ev.data); } catch { return; }
|
||||||
|
handleWsMessageRef.current(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
wsRef.current = null;
|
||||||
|
// Auto-reconnect if broadcasting or viewing (read from refs)
|
||||||
|
if (isBroadcastingRef.current || viewingRef.current) {
|
||||||
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
|
reconnectDelayRef.current = Math.min(reconnectDelayRef.current * 2, 10000);
|
||||||
|
connectWs();
|
||||||
|
}, reconnectDelayRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ── Start broadcasting ──
|
// ── Start broadcasting ──
|
||||||
const startBroadcast = useCallback(async () => {
|
const startBroadcast = useCallback(async () => {
|
||||||
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
|
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
|
||||||
|
|
||||||
// Check browser support
|
|
||||||
if (!navigator.mediaDevices?.getDisplayMedia) {
|
if (!navigator.mediaDevices?.getDisplayMedia) {
|
||||||
setError('Dein Browser unterstützt keine Bildschirmfreigabe.');
|
setError('Dein Browser unterstützt keine Bildschirmfreigabe.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -280,7 +307,6 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
// Connect WS and start broadcast
|
// Connect WS and start broadcast
|
||||||
connectWs();
|
connectWs();
|
||||||
|
|
||||||
// Wait for WS to open, then send start_broadcast
|
|
||||||
const waitForWs = () => {
|
const waitForWs = () => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share' });
|
wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share' });
|
||||||
|
|
@ -297,24 +323,23 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
setError(`Fehler: ${e.message}`);
|
setError(`Fehler: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [userName, streamTitle, connectWs]);
|
}, [userName, streamTitle, connectWs, wsSend]);
|
||||||
|
|
||||||
// ── Stop broadcasting ──
|
// ── Stop broadcasting ──
|
||||||
const stopBroadcast = useCallback(() => {
|
const stopBroadcast = useCallback(() => {
|
||||||
wsSend({ type: 'stop_broadcast' });
|
wsSend({ type: 'stop_broadcast' });
|
||||||
|
|
||||||
// Stop all tracks
|
|
||||||
localStreamRef.current?.getTracks().forEach(t => t.stop());
|
localStreamRef.current?.getTracks().forEach(t => t.stop());
|
||||||
localStreamRef.current = null;
|
localStreamRef.current = null;
|
||||||
if (localVideoRef.current) localVideoRef.current.srcObject = null;
|
if (localVideoRef.current) localVideoRef.current.srcObject = null;
|
||||||
|
|
||||||
// Close all peer connections
|
|
||||||
for (const pc of peerConnectionsRef.current.values()) pc.close();
|
for (const pc of peerConnectionsRef.current.values()) pc.close();
|
||||||
peerConnectionsRef.current.clear();
|
peerConnectionsRef.current.clear();
|
||||||
|
|
||||||
setIsBroadcasting(false);
|
setIsBroadcasting(false);
|
||||||
|
isBroadcastingRef.current = false;
|
||||||
setMyStreamId(null);
|
setMyStreamId(null);
|
||||||
}, []);
|
}, [wsSend]);
|
||||||
|
|
||||||
// ── Join as viewer ──
|
// ── Join as viewer ──
|
||||||
const joinStream = useCallback((streamId: string) => {
|
const joinStream = useCallback((streamId: string) => {
|
||||||
|
|
@ -330,22 +355,14 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
waitForWs();
|
waitForWs();
|
||||||
}, [userName, connectWs]);
|
}, [userName, connectWs, wsSend]);
|
||||||
|
|
||||||
// ── Leave viewer ──
|
// ── Leave viewer ──
|
||||||
const cleanupViewer = useCallback(() => {
|
|
||||||
if (viewerPcRef.current) {
|
|
||||||
viewerPcRef.current.close();
|
|
||||||
viewerPcRef.current = null;
|
|
||||||
}
|
|
||||||
if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const leaveViewing = useCallback(() => {
|
const leaveViewing = useCallback(() => {
|
||||||
wsSend({ type: 'leave_viewer' });
|
wsSend({ type: 'leave_viewer' });
|
||||||
cleanupViewer();
|
cleanupViewer();
|
||||||
setViewing(null);
|
setViewing(null);
|
||||||
}, [cleanupViewer]);
|
}, [cleanupViewer, wsSend]);
|
||||||
|
|
||||||
// ── Cleanup on unmount ──
|
// ── Cleanup on unmount ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue