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); // ── Admin / Notification Config ── const [showAdmin, setShowAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false); const [adminPwd, setAdminPwd] = useState(''); const [adminError, setAdminError] = useState(''); const [availableChannels, setAvailableChannels] = useState>([]); const [notifyConfig, setNotifyConfig] = useState>([]); const [configLoading, setConfigLoading] = useState(false); const [configSaving, setConfigSaving] = useState(false); const [notifyStatus, setNotifyStatus] = useState<{ online: boolean; botTag: string | null }>({ online: false, botTag: 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]); // Check admin status on mount useEffect(() => { fetch('/api/notifications/admin/status', { credentials: 'include' }) .then(r => r.json()) .then(d => setIsAdmin(d.admin === true)) .catch(() => {}); fetch('/api/notifications/status') .then(r => r.json()) .then(d => setNotifyStatus(d)) .catch(() => {}); }, []); // ── 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); if ((window as any).electronAPI?.showNotification) { (window as any).electronAPI.showNotification('Stream gestartet', 'Dein Stream ist jetzt live!'); } break; case 'stream_available': setStreams(prev => { if (prev.some(s => s.id === msg.streamId)) return prev; return [...prev, { id: msg.streamId, broadcasterName: msg.broadcasterName, title: msg.title, startedAt: new Date().toISOString(), viewerCount: 0, hasPassword: !!msg.hasPassword, }]; }); // Toast notification for new stream const notifBody = `${msg.broadcasterName} streamt: ${msg.title}`; if ((window as any).electronAPI?.showNotification) { (window as any).electronAPI.showNotification('Neuer Stream', notifBody); } else if (Notification.permission === 'granted') { new Notification('Neuer Stream', { body: notifBody, icon: '/assets/icon.png' }); } break; case 'stream_ended': setStreams(prev => prev.filter(s => s.id !== msg.streamId)); 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; // Always reconnect to keep stream list in sync reconnectTimerRef.current = setTimeout(() => { reconnectDelayRef.current = Math.min(reconnectDelayRef.current * 2, 10000); connectWs(); }, reconnectDelayRef.current); }; ws.onerror = () => { ws.close(); }; }, []); // ── Connect WS on mount for live stream updates ── useEffect(() => { connectWs(); return () => { if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); }; }, [connectWs]); // ── Start broadcasting ── const startBroadcast = useCallback(async () => { if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); 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() || undefined }); } 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 joinStreamDirectly = 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, wsSend]); const openJoinModal = useCallback((s: StreamInfo) => { if (s.hasPassword) { setJoinModal({ streamId: s.id, streamTitle: s.title, broadcasterName: s.broadcasterName, password: '', error: null }); } else { joinStreamDirectly(s.id); } }, [joinStreamDirectly]); 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]); // ── Admin functions ── const adminLogin = useCallback(async () => { setAdminError(''); try { const resp = await fetch('/api/notifications/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: adminPwd }), credentials: 'include', }); if (resp.ok) { setIsAdmin(true); setAdminPwd(''); loadNotifyConfig(); } else { const d = await resp.json(); setAdminError(d.error || 'Fehler'); } } catch { setAdminError('Verbindung fehlgeschlagen'); } }, [adminPwd]); const adminLogout = useCallback(async () => { await fetch('/api/notifications/admin/logout', { method: 'POST', credentials: 'include' }); setIsAdmin(false); setShowAdmin(false); }, []); const loadNotifyConfig = useCallback(async () => { setConfigLoading(true); try { const [chResp, cfgResp] = await Promise.all([ fetch('/api/notifications/channels', { credentials: 'include' }), fetch('/api/notifications/config', { credentials: 'include' }), ]); if (chResp.ok) { const chData = await chResp.json(); setAvailableChannels(chData.channels || []); } if (cfgResp.ok) { const cfgData = await cfgResp.json(); setNotifyConfig(cfgData.channels || []); } } catch { /* silent */ } finally { setConfigLoading(false); } }, []); const openAdmin = useCallback(() => { setShowAdmin(true); if (isAdmin) loadNotifyConfig(); }, [isAdmin, loadNotifyConfig]); const toggleChannelEvent = useCallback((channelId: string, channelName: string, guildId: string, guildName: string, event: string) => { console.log('[Notifications] Toggle:', channelId, channelName, event); setNotifyConfig(prev => { const existing = prev.find(c => c.channelId === channelId); if (existing) { const hasEvent = existing.events.includes(event); const newEvents = hasEvent ? existing.events.filter(e => e !== event) : [...existing.events, event]; if (newEvents.length === 0) { return prev.filter(c => c.channelId !== channelId); } return prev.map(c => c.channelId === channelId ? { ...c, events: newEvents } : c); } else { return [...prev, { channelId, channelName, guildId, guildName, events: [event] }]; } }); }, []); const saveNotifyConfig = useCallback(async () => { console.log('[Notifications] Saving config, notifyConfig:', JSON.stringify(notifyConfig)); setConfigSaving(true); try { const resp = await fetch('/api/notifications/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channels: notifyConfig }), credentials: 'include', }); if (resp.ok) { // brief visual feedback handled by configSaving state } } catch { /* silent */ } finally { setConfigSaving(false); } }, [notifyConfig]); const isChannelEventEnabled = useCallback((channelId: string, event: string): boolean => { const ch = notifyConfig.find(c => c.channelId === channelId); return ch?.events.includes(event) ?? false; }, [notifyConfig]); // ── 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 => (
openJoinModal(s)}>
{'\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 />
)} {/* ── Notification Admin Modal ── */} {showAdmin && (
setShowAdmin(false)}>
e.stopPropagation()}>

{'\uD83D\uDD14'} Benachrichtigungen

{!isAdmin ? (

Admin-Passwort eingeben:

setAdminPwd(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }} autoFocus />
{adminError &&

{adminError}

}
) : (
{notifyStatus.online ? <>{'\u2705'} Bot online: {notifyStatus.botTag} : <>{'\u26A0\uFE0F'} Bot offline — DISCORD_TOKEN_NOTIFICATIONS setzen}
{configLoading ? (
Lade Kan{'\u00E4'}le...
) : availableChannels.length === 0 ? (
{notifyStatus.online ? 'Keine Text-Kan\u00E4le gefunden. Bot hat m\u00F6glicherweise keinen Zugriff.' : 'Bot ist nicht verbunden. Bitte DISCORD_TOKEN_NOTIFICATIONS konfigurieren.'}
) : ( <>

W{'\u00E4'}hle die Kan{'\u00E4'}le, in die Benachrichtigungen gesendet werden sollen:

{availableChannels.map(ch => (
#{ch.channelName} {ch.guildName}
))}
)}
)}
)}
); }