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')}`; } // ── Quality Presets ── const QUALITY_PRESETS = [ { label: '720p60', width: 1280, height: 720, fps: 60, bitrate: 4_000_000 }, { label: '1080p60', width: 1920, height: 1080, fps: 60, bitrate: 8_000_000 }, { label: '2K60', width: 2560, height: 1440, fps: 60, bitrate: 14_000_000 }, { label: '4K60', width: 3840, height: 2160, fps: 60, bitrate: 25_000_000 }, { label: '4K165 Ultra', width: 3840, height: 2160, fps: 165, bitrate: 50_000_000 }, ] as const; // ── 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 [qualityIdx, setQualityIdx] = useState(1); // Default: 1080p60 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 remoteStreamRef = 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); const qualityRef = useRef(QUALITY_PRESETS[1]); useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]); useEffect(() => { viewingRef.current = viewing; }, [viewing]); useEffect(() => { qualityRef.current = QUALITY_PRESETS[qualityIdx]; }, [qualityIdx]); // 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); } }, []); // ── Attach remote stream to video element (handles autoplay) ── const attachRemoteStream = useCallback((videoEl: HTMLVideoElement, stream: MediaStream) => { videoEl.srcObject = stream; // Explicit play() to handle autoplay restrictions (e.g. fresh tab from Discord link) const playPromise = videoEl.play(); if (playPromise) { playPromise.catch(() => { // Autoplay blocked (no user interaction yet) → mute and retry videoEl.muted = true; videoEl.play().catch(() => {}); }); } }, []); // ── Viewer cleanup (only viewer PC, keeps broadcaster intact) ── const cleanupViewer = useCallback(() => { // Exit DOM fullscreen before the viewer element is unmounted if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}); } if (viewerPcRef.current) { viewerPcRef.current.close(); viewerPcRef.current = null; } remoteStreamRef.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() }); }; // Apply quality preset to WebRTC encoding 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 = qualityRef.current.fps; params.encodings[0].maxBitrate = qualityRef.current.bitrate; 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) => { const stream = ev.streams[0]; if (!stream) return; // Store stream in ref so it survives even if video element isn't mounted yet remoteStreamRef.current = stream; const videoEl = remoteVideoRef.current; if (videoEl) { attachRemoteStream(videoEl, stream); } // else: useEffect below will attach once video element is ready 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 q = qualityRef.current; const stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: q.fps }, width: { ideal: q.width }, height: { ideal: q.height } }, 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); }; }, []); // ── Attach remote stream when video element becomes available ── // Handles the race condition where ontrack fires before React renders the