import { useState, useEffect, useRef, useCallback } from 'react'; import './streaming.css'; // ── Types ── interface StreamInfo { id: string; broadcasterName: string; title: string; startedAt: string; viewerCount: number; } 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 [error, setError] = 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); const reconnectTimerRef = useRef | null>(null); const reconnectDelayRef = useRef(1000); // ── 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]); // ── WebSocket connect ── 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; }; 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) { case 'welcome': clientIdRef.current = msg.clientId; if (msg.streams) setStreams(msg.streams); break; case 'broadcast_started': setMyStreamId(msg.streamId); setIsBroadcasting(true); setStarting(false); break; case 'stream_available': // SSE will update streams list; this is just a hint break; case 'stream_ended': if (viewing?.streamId === msg.streamId) { cleanupViewer(); setViewing(null); } break; // ── Broadcaster: viewer joined → create offer ── case 'viewer_joined': { const viewerId = msg.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() }); } }; 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); } break; } // ── Viewer: received offer from broadcaster ── case 'offer': { 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: msg.fromId, 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(() => pc.createAnswer()) .then(answer => pc.setLocalDescription(answer)) .then(() => { wsSend({ type: 'answer', targetId: msg.fromId, 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)).catch(console.error); } break; } // ── ICE candidate relay ── case 'ice_candidate': { const pc = isBroadcasting ? peerConnectionsRef.current.get(msg.fromId) : viewerPcRef.current; if (pc && msg.candidate) { pc.addIceCandidate(new RTCIceCandidate(msg.candidate)).catch(() => {}); } break; } case 'error': setError(msg.message); setStarting(false); break; } }, [isBroadcasting, viewing]); // ── Send via WS ── const wsSend = (data: Record) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify(data)); } }; // ── Start broadcasting ── const startBroadcast = useCallback(async () => { if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; } // Check browser support 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(); // Wait for WS to open, then send start_broadcast const waitForWs = () => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share' }); } 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, connectWs]); // ── Stop broadcasting ── const stopBroadcast = useCallback(() => { wsSend({ type: 'stop_broadcast' }); // Stop all tracks localStreamRef.current?.getTracks().forEach(t => t.stop()); localStreamRef.current = null; if (localVideoRef.current) localVideoRef.current.srcObject = null; // Close all peer connections for (const pc of peerConnectionsRef.current.values()) pc.close(); peerConnectionsRef.current.clear(); setIsBroadcasting(false); setMyStreamId(null); }, []); // ── Join as viewer ── const joinStream = useCallback((streamId: string) => { setError(null); setViewing({ streamId, phase: 'connecting' }); connectWs(); const waitForWs = () => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsSend({ type: 'join_viewer', name: userName.trim() || 'Viewer', streamId }); } else { setTimeout(waitForWs, 100); } }; waitForWs(); }, [userName, connectWs]); // ── Leave viewer ── const cleanupViewer = useCallback(() => { if (viewerPcRef.current) { viewerPcRef.current.close(); viewerPcRef.current = null; } if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null; }, []); const leaveViewing = useCallback(() => { wsSend({ type: 'leave_viewer' }); cleanupViewer(); setViewing(null); }, [cleanupViewer]); // ── 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} /> {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 => (
joinStream(s.id)}>
{'\u{1F5A5}\uFE0F'} LIVE {'\u{1F465}'} {s.viewerCount}
{s.broadcasterName}
{s.title}
{formatElapsed(s.startedAt)}
))}
)}
); }