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); const [openMenu, setOpenMenu] = useState(null); const [copiedId, setCopiedId] = useState(null); // ── Refs ── const wsRef = useRef(null); const clientIdRef = useRef(''); const localStreamRef = useRef(null); const localVideoRef = useRef(null); const remoteVideoRef = useRef(null); const peerConnectionsRef = useRef>(new Map()); const viewerPcRef = useRef(null); const pendingCandidatesRef = useRef>(new Map()); const reconnectTimerRef = useRef | null>(null); const reconnectDelayRef = useRef(1000); // Refs that mirror state (avoid stale closures in WS handler) const isBroadcastingRef = useRef(false); const viewingRef = useRef(null); useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]); useEffect(() => { viewingRef.current = viewing; }, [viewing]); // Notify Electron about streaming status for close-warning useEffect(() => { (window as any).electronAPI?.setStreaming?.(isBroadcasting || viewing !== null); }, [isBroadcasting, 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]); // ── 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) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify(d)); } }, []); // ── 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); } 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 (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; // Only clear viewer-related pending candidates (not broadcaster ones) }, []); // ── 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; setStarting(false); break; case 'stream_available': 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; 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); 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() }); }; // 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 = [{}]; params.encodings[0].maxFramerate = 60; params.encodings[0].maxBitrate = 8_000_000; videoSender.setParameters(params).catch(() => {}); } pc.createOffer() .then(offer => pc.setLocalDescription(offer)) .then(() => wsSend({ type: 'offer', targetId: viewerId, sdp: pc.localDescription })) .catch(console.error); break; } 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; 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; } 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: try broadcaster map first, then viewer PC (supports dual role) ── case 'ice_candidate': { if (!msg.candidate) break; 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') { setJoinModal(prev => prev ? { ...prev, error: msg.message } : prev); } else { setError(msg.message); } setStarting(false); break; } }; // ── 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; } handleWsMessageRef.current(msg); }; ws.onclose = () => { wsRef.current = null; 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: { frameRate: { ideal: 60 }, width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: true, }); localStreamRef.current = stream; if (localVideoRef.current) localVideoRef.current.srcObject = stream; stream.getVideoTracks()[0]?.addEventListener('ended', () => { stopBroadcast(); }); 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 (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(); setIsBroadcasting(false); isBroadcastingRef.current = false; setMyStreamId(null); setStreamPassword(''); }, [wsSend]); // ── Join as viewer ── 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]); // ── Warn before leaving + beacon cleanup ── useEffect(() => { 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', beforeUnload); window.addEventListener('pagehide', pageHide); return () => { window.removeEventListener('beforeunload', beforeUnload); window.removeEventListener('pagehide', pageHide); }; }, []); // ── Fullscreen toggle ── const viewerContainerRef = useRef(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(() => {}); }, []); useEffect(() => { const handler = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', handler); return () => document.removeEventListener('fullscreenchange', handler); }, []); // ── 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); }; }, []); // ── Auto-join from URL ?viewStream=... ── const pendingViewStreamRef = useRef(null); // On mount: grab the streamId from URL and clear it useEffect(() => { const params = new URLSearchParams(location.search); const streamId = params.get('viewStream'); if (streamId) { pendingViewStreamRef.current = streamId; const url = new URL(location.href); url.searchParams.delete('viewStream'); window.history.replaceState({}, '', url.toString()); } }, []); // When streams update, check if we have a pending auto-join useEffect(() => { const pending = pendingViewStreamRef.current; if (!pending || streams.length === 0) return; const s = streams.find(st => st.id === pending); if (s) { pendingViewStreamRef.current = null; openJoinModal(s); } }, [streams, openJoinModal]); // ── 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 if (viewing) { const stream = streams.find(s => s.id === viewing.streamId); return (
LIVE
{stream?.title || 'Stream'}
{stream?.broadcasterName || '...'} {stream ? ` · ${stream.viewerCount} Zuschauer` : ''}
{viewing.phase === 'connecting' ? (
Verbindung wird hergestellt...
) : viewing.phase === 'error' ? (
{viewing.error || 'Verbindungsfehler'}
) : null}
); } return (
{error && (
{error}
)}
setUserName(e.target.value)} disabled={isBroadcasting} /> setStreamTitle(e.target.value)} disabled={isBroadcasting} /> setStreamPassword(e.target.value)} disabled={isBroadcasting} /> {isBroadcasting ? ( ) : ( )}
{streams.length === 0 && !isBroadcasting ? (
{'\u{1F4FA}'}

Keine aktiven Streams

Starte einen Stream, um deinen Bildschirm zu teilen.

) : (
{isBroadcasting && (
{userName} (Du)
{streamTitle}
{myStreamId && streams.find(s => s.id === myStreamId)?.startedAt ? formatElapsed(streams.find(s => s.id === myStreamId)!.startedAt) : '0:00'}
)} {streams .filter(s => s.id !== myStreamId) .map(s => (
openInNewWindow(s.id)}>
{'\u{1F5A5}\uFE0F'} LIVE {'\u{1F465}'} {s.viewerCount} {s.hasPassword && {'\u{1F512}'}}
{s.broadcasterName}
{s.title}
{formatElapsed(s.startedAt)}
{openMenu === s.id && (
e.stopPropagation()}>
{s.broadcasterName}
{s.title}
{'\u{1F465}'} {s.viewerCount} Zuschauer · {formatElapsed(s.startedAt)}
)}
))}
)} {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 />
)}
); }