import { useState, useEffect, useRef, useCallback } from 'react'; import './streaming.css'; // ── Types ── interface StreamInfo { id: string; broadcasterName: string; title: string; startedAt: string; viewerCount: number; hasPassword: boolean; } interface JoinModal { streamId: string; streamTitle: string; broadcasterName: string; password: string; error: string | null; } interface ViewState { streamId: string; phase: 'connecting' | 'connected' | 'error'; error?: string; } const RTC_CONFIG: RTCConfiguration = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, ], }; // ── Elapsed time helper ── function formatElapsed(startedAt: string): string { const diff = Math.max(0, Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000)); const h = Math.floor(diff / 3600); const m = Math.floor((diff % 3600) / 60); const s = diff % 60; if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; return `${m}:${String(s).padStart(2, '0')}`; } // ── Component ── export default function StreamingTab({ data }: { data: any }) { // ── State ── const [streams, setStreams] = useState([]); const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || ''); const [streamTitle, setStreamTitle] = useState('Screen Share'); const [streamPassword, setStreamPassword] = useState(''); const [error, setError] = useState(null); const [joinModal, setJoinModal] = useState(null); const [myStreamId, setMyStreamId] = useState(null); const [isBroadcasting, setIsBroadcasting] = useState(false); const [starting, setStarting] = useState(false); const [viewing, setViewing] = useState(null); const [, setTick] = useState(0); // for elapsed time re-render // ── Refs ── const wsRef = useRef(null); const clientIdRef = useRef(''); const localStreamRef = useRef(null); const localVideoRef = useRef(null); const remoteVideoRef = useRef(null); /** Broadcaster: one PeerConnection per viewer */ const peerConnectionsRef = useRef>(new Map()); /** Viewer: single PeerConnection to broadcaster */ const viewerPcRef = useRef(null); /** ICE candidate queue — candidates that arrived before setRemoteDescription */ const pendingCandidatesRef = useRef>(new Map()); const reconnectTimerRef = useRef | null>(null); const reconnectDelayRef = useRef(1000); // ── Refs that mirror state (to avoid stale closures in WS handler) ── const isBroadcastingRef = useRef(false); const viewingRef = useRef(null); useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]); useEffect(() => { viewingRef.current = viewing; }, [viewing]); // ── Elapsed time ticker ── useEffect(() => { const hasActive = streams.length > 0 || isBroadcasting; if (!hasActive) return; const iv = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(iv); }, [streams.length, isBroadcasting]); // ── SSE data → update stream list ── useEffect(() => { if (data?.streams) { setStreams(data.streams); } }, [data]); // ── Save name to localStorage ── useEffect(() => { if (userName) localStorage.setItem('streaming_name', userName); }, [userName]); // ── Send via WS ── const wsSend = useCallback((d: Record) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify(d)); } }, []); // ── 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) { viewerPcRef.current.close(); viewerPcRef.current = null; } if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null; pendingCandidatesRef.current.clear(); }, []); // ── WS message handler (uses refs, never stale) ── const handleWsMessageRef = useRef<(msg: any) => void>(() => {}); handleWsMessageRef.current = (msg: any) => { switch (msg.type) { case 'welcome': clientIdRef.current = msg.clientId; if (msg.streams) setStreams(msg.streams); break; case 'broadcast_started': setMyStreamId(msg.streamId); setIsBroadcasting(true); isBroadcastingRef.current = true; // immediate update for handler setStarting(false); break; case 'stream_available': // SSE will update streams list break; case 'stream_ended': if (viewingRef.current?.streamId === msg.streamId) { cleanupViewer(); setViewing(null); } break; // ── 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); // Add local stream tracks const stream = localStreamRef.current; if (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() }); } }; // 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); break; } // ── Broadcaster: viewer left → cleanup ── case 'viewer_left': { const pc = peerConnectionsRef.current.get(msg.viewerId); if (pc) { 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; pc.ontrack = (ev) => { 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() }); } }; pc.oniceconnectionstatechange = () => { if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') { setViewing(prev => prev ? { ...prev, phase: 'error', error: 'Verbindung verloren' } : prev); } }; pc.setRemoteDescription(new RTCSessionDescription(msg.sdp)) .then(() => { flushCandidates(pc, broadcasterId); return pc.createAnswer(); }) .then(answer => pc.setLocalDescription(answer)) .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) { pc.setRemoteDescription(new RTCSessionDescription(msg.sdp)) .then(() => flushCandidates(pc, msg.fromId)) .catch(console.error); } break; } // ── ICE candidate relay (queued until remote desc is set) ── 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); } 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); } setStarting(false); break; } }; // ── 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 ── 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; } setError(null); setStarting(true); try { const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true, }); localStreamRef.current = stream; // Show local preview if (localVideoRef.current) { localVideoRef.current.srcObject = stream; } // 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); } }; waitForWs(); } catch (e: any) { setStarting(false); if (e.name === 'NotAllowedError') { setError('Bildschirmfreigabe wurde abgelehnt.'); } else { setError(`Fehler: ${e.message}`); } } }, [userName, streamTitle, streamPassword, connectWs, wsSend]); // ── Stop broadcasting ── 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; setMyStreamId(null); setStreamPassword(''); }, [wsSend]); // ── Join as viewer (opens password modal first) ── const openJoinModal = useCallback((s: StreamInfo) => { setJoinModal({ streamId: s.id, streamTitle: s.title, broadcasterName: s.broadcasterName, password: '', error: null, }); }, []); const submitJoinModal = useCallback(() => { if (!joinModal) return; if (!joinModal.password.trim()) { setJoinModal(prev => prev ? { ...prev, error: 'Passwort eingeben.' } : prev); return; } const { streamId, password } = joinModal; setJoinModal(null); setError(null); setViewing({ streamId, phase: 'connecting' }); connectWs(); const waitForWs = () => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsSend({ type: 'join_viewer', name: userName.trim() || 'Viewer', streamId, password: password.trim() }); } else { setTimeout(waitForWs, 100); } }; waitForWs(); }, [joinModal, userName, connectWs, wsSend]); // ── Leave viewer ── const leaveViewing = useCallback(() => { wsSend({ type: 'leave_viewer' }); cleanupViewer(); setViewing(null); }, [cleanupViewer, wsSend]); // ── Cleanup on unmount ── useEffect(() => { return () => { localStreamRef.current?.getTracks().forEach(t => t.stop()); for (const pc of peerConnectionsRef.current.values()) pc.close(); if (viewerPcRef.current) viewerPcRef.current.close(); if (wsRef.current) wsRef.current.close(); if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); }; }, []); // ── Render ── // Fullscreen viewer overlay if (viewing) { const stream = streams.find(s => s.id === viewing.streamId); return (
LIVE
{stream?.title || 'Stream'}
{stream?.broadcasterName || '...'} {stream ? `\u00B7 ${stream.viewerCount} Zuschauer` : ''}
{viewing.phase === 'connecting' ? (
Verbindung wird hergestellt...
) : viewing.phase === 'error' ? (
{viewing.error || 'Verbindungsfehler'}
) : null}
); } return (
{/* Error */} {error && (
{error}
)} {/* Top bar: name, title, start/stop */}
setUserName(e.target.value)} disabled={isBroadcasting} /> setStreamTitle(e.target.value)} disabled={isBroadcasting} /> setStreamPassword(e.target.value)} disabled={isBroadcasting} /> {isBroadcasting ? ( ) : ( )}
{/* Grid */} {streams.length === 0 && !isBroadcasting ? (
{'\u{1F4FA}'}

Keine aktiven Streams

Starte einen Stream, um deinen Bildschirm zu teilen.

) : (
{/* Own broadcast tile (with local preview) */} {isBroadcasting && (
{userName} (Du)
{streamTitle}
{myStreamId && streams.find(s => s.id === myStreamId)?.startedAt ? formatElapsed(streams.find(s => s.id === myStreamId)!.startedAt) : '0:00'}
)} {/* Other streams */} {streams .filter(s => s.id !== myStreamId) .map(s => (
openJoinModal(s)}>
{'\u{1F5A5}\uFE0F'} LIVE {'\u{1F465}'} {s.viewerCount} {s.hasPassword && {'\u{1F512}'}}
{s.broadcasterName}
{s.title}
{formatElapsed(s.startedAt)}
))}
)} {/* Password join modal */} {joinModal && (
setJoinModal(null)}>
e.stopPropagation()}>

{joinModal.broadcasterName}

{joinModal.streamTitle}

{joinModal.error &&
{joinModal.error}
} setJoinModal(prev => prev ? { ...prev, password: e.target.value, error: null } : prev)} onKeyDown={e => { if (e.key === 'Enter') submitJoinModal(); }} autoFocus />
)}
); }