diff --git a/server/src/plugins/streaming/index.ts b/server/src/plugins/streaming/index.ts index b7fc679..b4f83cf 100644 --- a/server/src/plugins/streaming/index.ts +++ b/server/src/plugins/streaming/index.ts @@ -21,22 +21,25 @@ interface WsClient { role: 'idle' | 'broadcaster' | 'viewer'; name: string; streamId?: string; // ID of stream this client broadcasts or views + isAlive: boolean; // heartbeat tracking } // ── State ── -/** Active streams keyed by stream ID */ -const streams = new Map(); +/** Active streams keyed by stream ID (password stored server-side, never sent to clients) */ +const streams = new Map(); /** All connected WS clients */ const wsClients = new Map(); let wss: WebSocketServer | null = null; +let heartbeatInterval: ReturnType | null = null; +const HEARTBEAT_MS = 10_000; // ping every 10s +const HEARTBEAT_TIMEOUT = 25_000; // dead after missing ~2 pings // ── Helpers ── function broadcastStreamStatus(): void { - const list = [...streams.values()].map(({ broadcasterId: _, ...s }) => s); - sseBroadcast({ type: 'streaming_status', plugin: 'streaming', streams: list }); + sseBroadcast({ type: 'streaming_status', plugin: 'streaming', streams: getStreamList() }); } function sendTo(client: WsClient, data: Record): void { @@ -45,8 +48,11 @@ function sendTo(client: WsClient, data: Record): void { } } -function getStreamList(): StreamInfo[] { - return [...streams.values()].map(({ broadcasterId: _, ...s }) => s); +function getStreamList(): (StreamInfo & { hasPassword: boolean })[] { + return [...streams.values()].map(({ broadcasterId: _, password: pw, ...s }) => ({ + ...s, + hasPassword: pw.length > 0, + })); } function endStream(streamId: string, reason: string): void { @@ -85,6 +91,11 @@ function handleSignalingMessage(client: WsClient, msg: any): void { sendTo(client, { type: 'error', code: 'ALREADY_BROADCASTING', message: 'Du streamst bereits.' }); return; } + const password = String(msg.password || '').trim(); + if (!password) { + sendTo(client, { type: 'error', code: 'PASSWORD_REQUIRED', message: 'Passwort ist Pflicht.' }); + return; + } const streamId = crypto.randomUUID(); const name = String(msg.name || 'Anon').slice(0, 32); const title = String(msg.title || 'Screen Share').slice(0, 64); @@ -98,6 +109,7 @@ function handleSignalingMessage(client: WsClient, msg: any): void { broadcasterId: client.id, broadcasterName: name, title, + password, startedAt: new Date().toISOString(), viewerCount: 0, }); @@ -130,6 +142,12 @@ function handleSignalingMessage(client: WsClient, msg: any): void { sendTo(client, { type: 'error', code: 'NO_STREAM', message: 'Stream nicht gefunden.' }); return; } + // Validate password + const joinPw = String(msg.password || '').trim(); + if (stream.password && joinPw !== stream.password) { + sendTo(client, { type: 'error', code: 'WRONG_PASSWORD', message: 'Falsches Passwort.' }); + return; + } client.role = 'viewer'; client.name = String(msg.name || 'Viewer').slice(0, 32); @@ -215,6 +233,7 @@ const streamingPlugin: Plugin = { }, async destroy() { + if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } if (wss) { for (const client of wsClients.values()) { client.ws.close(1001, 'Server shutting down'); @@ -234,12 +253,16 @@ export function attachWebSocket(server: http.Server): void { wss.on('connection', (ws) => { const clientId = crypto.randomUUID(); - const client: WsClient = { id: clientId, ws, role: 'idle', name: '' }; + const client: WsClient = { id: clientId, ws, role: 'idle', name: '', isAlive: true }; wsClients.set(clientId, client); sendTo(client, { type: 'welcome', clientId, streams: getStreamList() }); + // Pong response marks client as alive + ws.on('pong', () => { client.isAlive = true; }); + ws.on('message', (raw) => { + client.isAlive = true; // any message = alive let msg: any; try { msg = JSON.parse(raw.toString()); } catch { return; } handleSignalingMessage(client, msg); @@ -256,6 +279,22 @@ export function attachWebSocket(server: http.Server): void { }); }); + // ── Heartbeat: detect dead connections ── + heartbeatInterval = setInterval(() => { + for (const [id, client] of wsClients) { + if (!client.isAlive) { + // No pong received since last check → dead + console.log(`[Streaming] Heartbeat timeout for ${client.name || id.slice(0, 8)} (role=${client.role})`); + handleDisconnect(client); + wsClients.delete(id); + client.ws.terminate(); + continue; + } + client.isAlive = false; + try { client.ws.ping(); } catch { /* ignore */ } + } + }, HEARTBEAT_MS); + console.log('[Streaming] WebSocket signaling attached at /ws/streaming'); } diff --git a/web/src/plugins/streaming/StreamingTab.tsx b/web/src/plugins/streaming/StreamingTab.tsx index 40121dc..8e22609 100644 --- a/web/src/plugins/streaming/StreamingTab.tsx +++ b/web/src/plugins/streaming/StreamingTab.tsx @@ -9,6 +9,15 @@ interface StreamInfo { title: string; startedAt: string; viewerCount: number; + hasPassword: boolean; +} + +interface JoinModal { + streamId: string; + streamTitle: string; + broadcasterName: string; + password: string; + error: string | null; } interface ViewState { @@ -41,7 +50,9 @@ export default function StreamingTab({ data }: { data: any }) { 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); @@ -234,7 +245,12 @@ export default function StreamingTab({ data }: { data: any }) { } case 'error': - setError(msg.message); + 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; } @@ -278,6 +294,7 @@ export default function StreamingTab({ data }: { data: any }) { // ── 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.'); @@ -309,7 +326,7 @@ export default function StreamingTab({ data }: { data: any }) { const waitForWs = () => { if (wsRef.current?.readyState === WebSocket.OPEN) { - wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share' }); + wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share', password: streamPassword.trim() }); } else { setTimeout(waitForWs, 100); } @@ -323,7 +340,7 @@ export default function StreamingTab({ data }: { data: any }) { setError(`Fehler: ${e.message}`); } } - }, [userName, streamTitle, connectWs, wsSend]); + }, [userName, streamTitle, streamPassword, connectWs, wsSend]); // ── Stop broadcasting ── const stopBroadcast = useCallback(() => { @@ -339,23 +356,41 @@ export default function StreamingTab({ data }: { data: any }) { setIsBroadcasting(false); isBroadcastingRef.current = false; setMyStreamId(null); + setStreamPassword(''); }, [wsSend]); - // ── Join as viewer ── - const joinStream = useCallback((streamId: string) => { + // ── 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 }); + wsSend({ type: 'join_viewer', name: userName.trim() || 'Viewer', streamId, password: password.trim() }); } else { setTimeout(waitForWs, 100); } }; waitForWs(); - }, [userName, connectWs, wsSend]); + }, [joinModal, userName, connectWs, wsSend]); // ── Leave viewer ── const leaveViewing = useCallback(() => { @@ -438,6 +473,14 @@ export default function StreamingTab({ data }: { data: any }) { onChange={e => setStreamTitle(e.target.value)} disabled={isBroadcasting} /> + setStreamPassword(e.target.value)} + disabled={isBroadcasting} + /> {isBroadcasting ? ( + + + + + )} ); } diff --git a/web/src/plugins/streaming/streaming.css b/web/src/plugins/streaming/streaming.css index 204c36c..8b828c5 100644 --- a/web/src/plugins/streaming/streaming.css +++ b/web/src/plugins/streaming/streaming.css @@ -324,3 +324,72 @@ border: 2px solid var(--danger); border-bottom: none; } + +/* ── Password input in topbar ── */ +.stream-input-password { + width: 140px; +} + +/* ── Lock icon on tile ── */ +.stream-tile-lock { + position: absolute; + bottom: 8px; + right: 8px; + font-size: 16px; + opacity: 0.6; +} + +/* ── Password modal ── */ +.stream-pw-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; +} +.stream-pw-modal { + background: var(--bg-secondary); + border-radius: var(--radius-lg); + padding: 24px; + width: 340px; + max-width: 90vw; +} +.stream-pw-modal h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 4px; +} +.stream-pw-modal p { + font-size: 13px; + color: var(--text-muted); + margin-bottom: 16px; +} +.stream-pw-modal .stream-input { + width: 100%; + margin-bottom: 12px; +} +.stream-pw-modal-error { + color: var(--danger); + font-size: 13px; + margin-bottom: 8px; +} +.stream-pw-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} +.stream-pw-cancel { + padding: 8px 16px; + border: 1px solid var(--bg-tertiary); + border-radius: var(--radius); + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; +} +.stream-pw-cancel:hover { + color: var(--text-normal); + border-color: var(--text-faint); +}