From e146a284165f65de7b161d8b2c8474cb9db7ffb0 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 8 Mar 2026 00:16:42 +0100 Subject: [PATCH] Streaming: Bildschirmauswahl-Picker, Passwort optional, Windows-Toast-Notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Electron: setDisplayMediaRequestHandler zeigt jetzt immer einen modalen Picker mit Thumbnails statt automatisch die letzte Quelle zu verwenden - Passwort bei Streams ist jetzt optional (Server + Frontend) - Streams ohne Passwort: direkter Beitritt ohne Modal - hasPassword wird korrekt im stream_available Event übertragen - Windows Toast-Notifications via Electron Notification API für "Stream gestartet" und "Neuer Stream" Events - Browser-Variante nutzt weiterhin Web Notification API Co-Authored-By: Claude Opus 4.6 --- electron/main.js | 97 ++++++++++++++++++++-- electron/preload.js | 1 + server/src/plugins/streaming/index.ts | 6 +- web/src/plugins/streaming/StreamingTab.tsx | 40 ++++++--- 4 files changed, 123 insertions(+), 21 deletions(-) diff --git a/electron/main.js b/electron/main.js index 632f71b..ef0829d 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, session, shell, desktopCapturer, autoUpdater, dialog, ipcMain } = require('electron'); +const { app, BrowserWindow, session, shell, desktopCapturer, autoUpdater, dialog, ipcMain, Notification } = require('electron'); const path = require('path'); const { setupAdBlocker } = require('./ad-blocker'); @@ -92,10 +92,89 @@ function createWindow() { // Setup ad blocker BEFORE loading URL setupAdBlocker(session.defaultSession); - // Enable screen capture (getDisplayMedia) in Electron - session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => { - desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => { - callback({ video: sources[0], audio: 'loopback' }); + // Enable screen capture (getDisplayMedia) in Electron — always show picker + session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => { + const sources = await desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: { width: 320, height: 180 } }); + if (sources.length === 0) { + callback({}); + return; + } + + // Build a picker window so the user always chooses + const picker = new BrowserWindow({ + width: 680, + height: 520, + parent: mainWindow, + modal: true, + resizable: false, + minimizable: false, + maximizable: false, + title: 'Bildschirm / Fenster ausw\u00e4hlen', + backgroundColor: '#1a1b1e', + autoHideMenuBar: true, + webPreferences: { contextIsolation: true, nodeIntegration: false }, + }); + + const sourceData = sources.map(s => ({ + id: s.id, + name: s.name, + thumbnail: s.thumbnail.toDataURL(), + })); + + const html = ` + +

Bildschirm oder Fenster ausw\u00e4hlen

+
+
+`; + + picker.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html)); + + let resolved = false; + picker.webContents.on('page-title-updated', (_ev, title) => { + if (!title.startsWith('PICK:')) return; + resolved = true; + const selectedId = title.slice(5); + picker.close(); + if (!selectedId) { + callback({}); + return; + } + const chosen = sources.find(s => s.id === selectedId); + if (chosen) { + callback({ video: chosen, audio: 'loopback' }); + } else { + callback({}); + } + }); + + picker.on('closed', () => { + if (!resolved) callback({}); }); }); @@ -126,6 +205,14 @@ function createWindow() { let isStreaming = false; ipcMain.on('streaming-status', (_event, active) => { isStreaming = active; }); + // Windows toast notifications from renderer + ipcMain.on('show-notification', (_event, title, body) => { + if (Notification.isSupported()) { + const notif = new Notification({ title, body, icon: path.join(__dirname, 'assets', 'icon.png') }); + notif.show(); + } + }); + // Warn before closing if a stream is active mainWindow.on('close', (event) => { if (!isStreaming) return; diff --git a/electron/preload.js b/electron/preload.js index a53910d..ff2fc1b 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -11,4 +11,5 @@ contextBridge.exposeInMainWorld('electronAPI', { checkForUpdates: () => ipcRenderer.send('check-for-updates'), installUpdate: () => ipcRenderer.send('install-update'), setStreaming: (active) => ipcRenderer.send('streaming-status', active), + showNotification: (title, body) => ipcRenderer.send('show-notification', title, body), }); diff --git a/server/src/plugins/streaming/index.ts b/server/src/plugins/streaming/index.ts index 4d26b9c..20b1b20 100644 --- a/server/src/plugins/streaming/index.ts +++ b/server/src/plugins/streaming/index.ts @@ -103,10 +103,6 @@ function handleSignalingMessage(client: WsClient, msg: any): void { 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); @@ -130,7 +126,7 @@ function handleSignalingMessage(client: WsClient, msg: any): void { // Notify all other clients for (const c of wsClients.values()) { if (c.id !== client.id) { - sendTo(c, { type: 'stream_available', streamId, broadcasterName: name, title }); + sendTo(c, { type: 'stream_available', streamId, broadcasterName: name, title, hasPassword: password.length > 0 }); } } console.log(`[Streaming] ${name} started "${title}" (${streamId.slice(0, 8)})`); diff --git a/web/src/plugins/streaming/StreamingTab.tsx b/web/src/plugins/streaming/StreamingTab.tsx index 56493ff..555dd21 100644 --- a/web/src/plugins/streaming/StreamingTab.tsx +++ b/web/src/plugins/streaming/StreamingTab.tsx @@ -162,6 +162,9 @@ export default function StreamingTab({ data }: { data: any }) { 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': @@ -173,15 +176,15 @@ export default function StreamingTab({ data }: { data: any }) { title: msg.title, startedAt: new Date().toISOString(), viewerCount: 0, - hasPassword: true, + hasPassword: !!msg.hasPassword, }]; }); // Toast notification for new stream - if (Notification.permission === 'granted') { - new Notification('Neuer Stream', { - body: `${msg.broadcasterName} streamt: ${msg.title}`, - icon: '/assets/icon.png', - }); + 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; @@ -343,7 +346,6 @@ 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.'); return; @@ -365,7 +367,7 @@ export default function StreamingTab({ data }: { data: any }) { connectWs(); const waitForWs = () => { if (wsRef.current?.readyState === WebSocket.OPEN) { - wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share', password: streamPassword.trim() }); + wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share', password: streamPassword.trim() || undefined }); } else { setTimeout(waitForWs, 100); } }; waitForWs(); @@ -393,9 +395,25 @@ export default function StreamingTab({ data }: { data: any }) { }, [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) => { - setJoinModal({ streamId: s.id, streamTitle: s.title, broadcasterName: s.broadcasterName, password: '', error: null }); - }, []); + 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; @@ -587,7 +605,7 @@ export default function StreamingTab({ data }: { data: any }) { setStreamPassword(e.target.value)} disabled={isBroadcasting}