Streaming: Stale-Stream Fix, Broadcast+View gleichzeitig, 3-Punkt-Menü

Server:
- Dual-Role: Client kann gleichzeitig broadcasten UND zuschauen
  (broadcastStreamId + viewingStreamId statt single role)
- POST /api/streaming/disconnect Beacon-Endpoint fuer
  zuverlaessigen Cleanup bei Page-Unload
- Heartbeat auf 5s reduziert (schnellere Erkennung)

Frontend:
- pagehide + sendBeacon: Streams werden sofort aufgeraeumt wenn
  Browser geschlossen/neugeladen wird
- ICE Routing: Broadcaster-Map wird zuerst geprueft, dann Viewer-PC
  → Broadcast + View im selben Tab moeglich
- 3-Punkt-Menü mit Stream-Details, "In neuem Fenster oeffnen" und
  "Link teilen" (Clipboard)
- Auto-Join via ?viewStream=... Query-Parameter (fuer geteilte Links)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-07 01:56:14 +01:00
parent 813e017036
commit 470bef62e4
7 changed files with 5091 additions and 5013 deletions

View file

@ -57,7 +57,9 @@ export default function StreamingTab({ data }: { data: any }) {
const [isBroadcasting, setIsBroadcasting] = useState(false);
const [starting, setStarting] = useState(false);
const [viewing, setViewing] = useState<ViewState | null>(null);
const [, setTick] = useState(0); // for elapsed time re-render
const [, setTick] = useState(0);
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
// ── Refs ──
const wsRef = useRef<WebSocket | null>(null);
@ -65,16 +67,13 @@ export default function StreamingTab({ data }: { data: any }) {
const localStreamRef = useRef<MediaStream | null>(null);
const localVideoRef = useRef<HTMLVideoElement | null>(null);
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
/** Broadcaster: one PeerConnection per viewer */
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);
// ── Refs that mirror state (to avoid stale closures in WS handler) ──
// Refs that mirror state (avoid stale closures in WS handler)
const isBroadcastingRef = useRef(false);
const viewingRef = useRef<ViewState | null>(null);
useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]);
@ -100,6 +99,14 @@ export default function StreamingTab({ data }: { data: any }) {
if (userName) localStorage.setItem('streaming_name', userName);
}, [userName]);
// ── Close tile menu on outside click ──
useEffect(() => {
if (!openMenu) return;
const handler = () => setOpenMenu(null);
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, [openMenu]);
// ── Send via WS ──
const wsSend = useCallback((d: Record<string, any>) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
@ -107,16 +114,13 @@ export default function StreamingTab({ data }: { data: any }) {
}
}, []);
// ── ICE candidate queuing (prevents candidates arriving before remote desc) ──
// ── ICE candidate queuing ──
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);
}
if (!queue) { queue = []; pendingCandidatesRef.current.set(peerId, queue); }
queue.push(candidate);
}
}, []);
@ -124,21 +128,19 @@ export default function StreamingTab({ data }: { data: any }) {
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(() => {});
}
for (const c of queue) pc.addIceCandidate(new RTCIceCandidate(c)).catch(() => {});
pendingCandidatesRef.current.delete(peerId);
}
}, []);
// ── Viewer cleanup ──
// ── Viewer cleanup (only viewer PC, keeps broadcaster intact) ──
const cleanupViewer = useCallback(() => {
if (viewerPcRef.current) {
viewerPcRef.current.close();
viewerPcRef.current = null;
}
if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null;
pendingCandidatesRef.current.clear();
// Only clear viewer-related pending candidates (not broadcaster ones)
}, []);
// ── WS message handler (uses refs, never stale) ──
@ -153,12 +155,11 @@ export default function StreamingTab({ data }: { data: any }) {
case 'broadcast_started':
setMyStreamId(msg.streamId);
setIsBroadcasting(true);
isBroadcastingRef.current = true; // immediate update for handler
isBroadcastingRef.current = true;
setStarting(false);
break;
case 'stream_available':
// SSE will update streams list
break;
case 'stream_ended':
@ -171,8 +172,6 @@ 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();
@ -183,49 +182,35 @@ export default function StreamingTab({ data }: { data: any }) {
const pc = new RTCPeerConnection(RTC_CONFIG);
peerConnectionsRef.current.set(viewerId, pc);
// Add local stream tracks
const stream = localStreamRef.current;
if (stream) {
for (const track of stream.getTracks()) {
pc.addTrack(track, stream);
}
for (const track of stream.getTracks()) pc.addTrack(track, stream);
}
pc.onicecandidate = (ev) => {
if (ev.candidate) {
wsSend({ type: 'ice_candidate', targetId: viewerId, candidate: ev.candidate.toJSON() });
}
if (ev.candidate) wsSend({ type: 'ice_candidate', targetId: viewerId, candidate: ev.candidate.toJSON() });
};
// Boost encoding: 60 fps + higher bitrate for smooth video
// 60 fps + high bitrate
const videoSender = pc.getSenders().find(s => s.track?.kind === 'video');
if (videoSender) {
const params = videoSender.getParameters();
if (!params.encodings || params.encodings.length === 0) {
params.encodings = [{}];
}
if (!params.encodings || params.encodings.length === 0) params.encodings = [{}];
params.encodings[0].maxFramerate = 60;
params.encodings[0].maxBitrate = 8_000_000; // 8 Mbps
params.encodings[0].maxBitrate = 8_000_000;
videoSender.setParameters(params).catch(() => {});
}
// Single offer (no onnegotiationneeded — tracks already added above)
pc.createOffer()
.then(offer => pc.setLocalDescription(offer))
.then(() => {
wsSend({ type: 'offer', targetId: viewerId, sdp: pc.localDescription });
})
.then(() => wsSend({ type: 'offer', targetId: viewerId, sdp: pc.localDescription }))
.catch(console.error);
break;
}
// ── Broadcaster: viewer left → cleanup ──
case 'viewer_left': {
const pc = peerConnectionsRef.current.get(msg.viewerId);
if (pc) {
pc.close();
peerConnectionsRef.current.delete(msg.viewerId);
}
if (pc) { pc.close(); peerConnectionsRef.current.delete(msg.viewerId); }
pendingCandidatesRef.current.delete(msg.viewerId);
break;
}
@ -233,28 +218,19 @@ export default function StreamingTab({ data }: { data: any }) {
// ── 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;
}
if (viewerPcRef.current) { viewerPcRef.current.close(); viewerPcRef.current = null; }
pendingCandidatesRef.current.delete(broadcasterId);
const pc = new RTCPeerConnection(RTC_CONFIG);
viewerPcRef.current = pc;
pc.ontrack = (ev) => {
if (remoteVideoRef.current && ev.streams[0]) {
remoteVideoRef.current.srcObject = ev.streams[0];
}
if (remoteVideoRef.current && ev.streams[0]) remoteVideoRef.current.srcObject = ev.streams[0];
setViewing(prev => prev ? { ...prev, phase: 'connected' } : prev);
};
pc.onicecandidate = (ev) => {
if (ev.candidate) {
wsSend({ type: 'ice_candidate', targetId: broadcasterId, candidate: ev.candidate.toJSON() });
}
if (ev.candidate) wsSend({ type: 'ice_candidate', targetId: broadcasterId, candidate: ev.candidate.toJSON() });
};
pc.oniceconnectionstatechange = () => {
@ -264,19 +240,13 @@ export default function StreamingTab({ data }: { data: any }) {
};
pc.setRemoteDescription(new RTCSessionDescription(msg.sdp))
.then(() => {
flushCandidates(pc, broadcasterId);
return pc.createAnswer();
})
.then(() => { flushCandidates(pc, broadcasterId); return pc.createAnswer(); })
.then(answer => pc.setLocalDescription(answer))
.then(() => {
wsSend({ type: 'answer', targetId: broadcasterId, sdp: pc.localDescription });
})
.then(() => wsSend({ type: 'answer', targetId: broadcasterId, sdp: pc.localDescription }))
.catch(console.error);
break;
}
// ── Broadcaster: received answer from viewer ──
case 'answer': {
const pc = peerConnectionsRef.current.get(msg.fromId);
if (pc) {
@ -287,22 +257,20 @@ export default function StreamingTab({ data }: { data: any }) {
break;
}
// ── ICE candidate relay (queued until remote desc is set) ──
// ── ICE: try broadcaster map first, then viewer PC (supports dual role) ──
case 'ice_candidate': {
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);
const broadcasterPc = peerConnectionsRef.current.get(msg.fromId);
if (broadcasterPc) {
addOrQueueCandidate(broadcasterPc, msg.fromId, msg.candidate);
} else if (viewerPcRef.current) {
addOrQueueCandidate(viewerPcRef.current, msg.fromId, msg.candidate);
}
break;
}
case 'error':
if (msg.code === 'WRONG_PASSWORD') {
// Show error inside join modal
setJoinModal(prev => prev ? { ...prev, error: msg.message } : prev);
} else {
setError(msg.message);
@ -312,7 +280,7 @@ export default function StreamingTab({ data }: { data: any }) {
}
};
// ── WebSocket connect (stable, no state deps) ──
// ── WebSocket connect ──
const connectWs = useCallback(() => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return;
@ -320,11 +288,8 @@ export default function StreamingTab({ data }: { data: any }) {
const ws = new WebSocket(`${proto}://${location.host}/ws/streaming`);
wsRef.current = ws;
ws.onopen = () => {
reconnectDelayRef.current = 1000;
};
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; }
@ -333,7 +298,6 @@ export default function StreamingTab({ data }: { data: any }) {
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);
@ -342,16 +306,13 @@ export default function StreamingTab({ data }: { data: any }) {
}
};
ws.onerror = () => {
ws.close();
};
ws.onerror = () => { ws.close(); };
}, []);
// ── Start broadcasting ──
const startBroadcast = useCallback(async () => {
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
if (!streamPassword.trim()) { setError('Passwort ist Pflicht.'); return; }
if (!navigator.mediaDevices?.getDisplayMedia) {
setError('Dein Browser unterstützt keine Bildschirmfreigabe.');
return;
@ -366,49 +327,33 @@ export default function StreamingTab({ data }: { data: any }) {
audio: true,
});
localStreamRef.current = stream;
if (localVideoRef.current) localVideoRef.current.srcObject = stream;
// Show local preview
if (localVideoRef.current) {
localVideoRef.current.srcObject = stream;
}
stream.getVideoTracks()[0]?.addEventListener('ended', () => { stopBroadcast(); });
// Auto-stop when user clicks native "Stop sharing"
stream.getVideoTracks()[0]?.addEventListener('ended', () => {
stopBroadcast();
});
// Connect WS and start broadcast
connectWs();
const waitForWs = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share', password: streamPassword.trim() });
} else {
setTimeout(waitForWs, 100);
}
} else { setTimeout(waitForWs, 100); }
};
waitForWs();
} catch (e: any) {
setStarting(false);
if (e.name === 'NotAllowedError') {
setError('Bildschirmfreigabe wurde abgelehnt.');
} else {
setError(`Fehler: ${e.message}`);
}
if (e.name === 'NotAllowedError') setError('Bildschirmfreigabe wurde abgelehnt.');
else setError(`Fehler: ${e.message}`);
}
}, [userName, streamTitle, streamPassword, connectWs, wsSend]);
// ── Stop broadcasting ──
// ── Stop broadcasting (keeps viewer connection intact) ──
const stopBroadcast = useCallback(() => {
wsSend({ type: 'stop_broadcast' });
localStreamRef.current?.getTracks().forEach(t => t.stop());
localStreamRef.current = null;
if (localVideoRef.current) localVideoRef.current.srcObject = null;
for (const pc of peerConnectionsRef.current.values()) pc.close();
peerConnectionsRef.current.clear();
pendingCandidatesRef.current.clear();
setIsBroadcasting(false);
isBroadcastingRef.current = false;
@ -416,15 +361,9 @@ export default function StreamingTab({ data }: { data: any }) {
setStreamPassword('');
}, [wsSend]);
// ── Join as viewer (opens password modal first) ──
// ── Join as viewer ──
const openJoinModal = useCallback((s: StreamInfo) => {
setJoinModal({
streamId: s.id,
streamTitle: s.title,
broadcasterName: s.broadcasterName,
password: '',
error: null,
});
setJoinModal({ streamId: s.id, streamTitle: s.title, broadcasterName: s.broadcasterName, password: '', error: null });
}, []);
const submitJoinModal = useCallback(() => {
@ -442,9 +381,7 @@ export default function StreamingTab({ data }: { data: any }) {
const waitForWs = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsSend({ type: 'join_viewer', name: userName.trim() || 'Viewer', streamId, password: password.trim() });
} else {
setTimeout(waitForWs, 100);
}
} else { setTimeout(waitForWs, 100); }
};
waitForWs();
}, [joinModal, userName, connectWs, wsSend]);
@ -456,29 +393,34 @@ export default function StreamingTab({ data }: { data: any }) {
setViewing(null);
}, [cleanupViewer, wsSend]);
// ── Warn before leaving page while active ──
// ── Warn before leaving + beacon cleanup ──
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (isBroadcastingRef.current || viewingRef.current) {
e.preventDefault();
const beforeUnload = (e: BeforeUnloadEvent) => {
if (isBroadcastingRef.current || viewingRef.current) e.preventDefault();
};
const pageHide = () => {
// Guaranteed delivery via sendBeacon
if (clientIdRef.current) {
navigator.sendBeacon('/api/streaming/disconnect', JSON.stringify({ clientId: clientIdRef.current }));
}
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
window.addEventListener('beforeunload', beforeUnload);
window.addEventListener('pagehide', pageHide);
return () => {
window.removeEventListener('beforeunload', beforeUnload);
window.removeEventListener('pagehide', pageHide);
};
}, []);
// ── Fullscreen toggle for viewer ──
// ── Fullscreen toggle ──
const viewerContainerRef = useRef<HTMLDivElement | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const toggleFullscreen = useCallback(() => {
const el = viewerContainerRef.current;
if (!el) return;
if (!document.fullscreenElement) {
el.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
if (!document.fullscreenElement) el.requestFullscreen().catch(() => {});
else document.exitFullscreen().catch(() => {});
}, []);
useEffect(() => {
@ -498,6 +440,50 @@ export default function StreamingTab({ data }: { data: any }) {
};
}, []);
// ── Auto-join from URL ?viewStream=... ──
useEffect(() => {
const params = new URLSearchParams(location.search);
const streamId = params.get('viewStream');
if (!streamId) return;
// Clean up URL
const url = new URL(location.href);
url.searchParams.delete('viewStream');
window.history.replaceState({}, '', url.toString());
// Wait for streams to load, then open join modal
const tryOpen = () => {
const s = streams.find(st => st.id === streamId);
if (s) { openJoinModal(s); return; }
// Retry a few times (stream list may not be loaded yet)
let tries = 0;
const iv = setInterval(() => {
tries++;
if (tries > 20) { clearInterval(iv); return; }
}, 500);
};
setTimeout(tryOpen, 500);
}, []);
// ── Helpers for 3-dot menu ──
const buildStreamLink = useCallback((streamId: string) => {
const url = new URL(location.href);
url.searchParams.set('viewStream', streamId);
// Make sure we're on the streaming tab
url.hash = '';
return url.toString();
}, []);
const copyStreamLink = useCallback((streamId: string) => {
navigator.clipboard.writeText(buildStreamLink(streamId)).then(() => {
setCopiedId(streamId);
setTimeout(() => setCopiedId(null), 2000);
}).catch(() => {});
}, [buildStreamLink]);
const openInNewWindow = useCallback((streamId: string) => {
window.open(buildStreamLink(streamId), '_blank', 'noopener');
setOpenMenu(null);
}, [buildStreamLink]);
// ── Render ──
// Fullscreen viewer overlay
@ -511,7 +497,7 @@ export default function StreamingTab({ data }: { data: any }) {
<div>
<div className="stream-viewer-title">{stream?.title || 'Stream'}</div>
<div className="stream-viewer-subtitle">
{stream?.broadcasterName || '...'} {stream ? `\u00B7 ${stream.viewerCount} Zuschauer` : ''}
{stream?.broadcasterName || '...'} {stream ? ` · ${stream.viewerCount} Zuschauer` : ''}
</div>
</div>
</div>
@ -542,7 +528,6 @@ export default function StreamingTab({ data }: { data: any }) {
return (
<div className="stream-container">
{/* Error */}
{error && (
<div className="stream-error">
{error}
@ -550,7 +535,6 @@ export default function StreamingTab({ data }: { data: any }) {
</div>
)}
{/* Top bar: name, title, start/stop */}
<div className="stream-topbar">
<input
className="stream-input stream-input-name"
@ -585,7 +569,6 @@ export default function StreamingTab({ data }: { data: any }) {
)}
</div>
{/* Grid */}
{streams.length === 0 && !isBroadcasting ? (
<div className="stream-empty">
<div className="stream-empty-icon">{'\u{1F4FA}'}</div>
@ -594,7 +577,6 @@ export default function StreamingTab({ data }: { data: any }) {
</div>
) : (
<div className="stream-grid">
{/* Own broadcast tile (with local preview) */}
{isBroadcasting && (
<div className="stream-tile own broadcasting">
<div className="stream-tile-preview">
@ -618,7 +600,6 @@ export default function StreamingTab({ data }: { data: any }) {
</div>
)}
{/* Other streams */}
{streams
.filter(s => s.id !== myStreamId)
.map(s => (
@ -635,14 +616,38 @@ export default function StreamingTab({ data }: { data: any }) {
<div className="stream-tile-title">{s.title}</div>
</div>
<span className="stream-tile-time">{formatElapsed(s.startedAt)}</span>
<button className="stream-tile-menu" onClick={e => e.stopPropagation()}>{'\u22EE'}</button>
<div className="stream-tile-menu-wrap">
<button
className="stream-tile-menu"
onClick={e => { e.stopPropagation(); setOpenMenu(openMenu === s.id ? null : s.id); }}
>
{'\u22EE'}
</button>
{openMenu === s.id && (
<div className="stream-tile-dropdown" onClick={e => e.stopPropagation()}>
<div className="stream-tile-dropdown-header">
<div className="stream-tile-dropdown-name">{s.broadcasterName}</div>
<div className="stream-tile-dropdown-title">{s.title}</div>
<div className="stream-tile-dropdown-detail">
{'\u{1F465}'} {s.viewerCount} Zuschauer · {formatElapsed(s.startedAt)}
</div>
</div>
<div className="stream-tile-dropdown-divider" />
<button className="stream-tile-dropdown-item" onClick={() => openInNewWindow(s.id)}>
{'\u{1F5D7}'} In neuem Fenster öffnen
</button>
<button className="stream-tile-dropdown-item" onClick={() => { copyStreamLink(s.id); setOpenMenu(null); }}>
{copiedId === s.id ? '\u2705 Kopiert!' : '\u{1F517} Link teilen'}
</button>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* Password join modal */}
{joinModal && (
<div className="stream-pw-overlay" onClick={() => setJoinModal(null)}>
<div className="stream-pw-modal" onClick={e => e.stopPropagation()}>