Streaming: Bildschirmauswahl-Picker, Passwort optional, Windows-Toast-Notifications
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
fa964318f2
commit
e146a28416
4 changed files with 123 additions and 21 deletions
|
|
@ -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 path = require('path');
|
||||||
const { setupAdBlocker } = require('./ad-blocker');
|
const { setupAdBlocker } = require('./ad-blocker');
|
||||||
|
|
||||||
|
|
@ -92,10 +92,89 @@ function createWindow() {
|
||||||
// Setup ad blocker BEFORE loading URL
|
// Setup ad blocker BEFORE loading URL
|
||||||
setupAdBlocker(session.defaultSession);
|
setupAdBlocker(session.defaultSession);
|
||||||
|
|
||||||
// Enable screen capture (getDisplayMedia) in Electron
|
// Enable screen capture (getDisplayMedia) in Electron — always show picker
|
||||||
session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => {
|
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
|
||||||
desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => {
|
const sources = await desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: { width: 320, height: 180 } });
|
||||||
callback({ video: sources[0], audio: 'loopback' });
|
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 = `<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{background:#1a1b1e;color:#e0e0e0;font-family:system-ui,sans-serif;padding:16px;overflow-y:auto}
|
||||||
|
h2{font-size:16px;margin-bottom:12px;color:#ccc}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
|
||||||
|
.item{background:#2a2a3e;border:2px solid transparent;border-radius:10px;cursor:pointer;overflow:hidden;transition:border-color .15s,transform .15s}
|
||||||
|
.item:hover{border-color:#7c5cff;transform:scale(1.03)}
|
||||||
|
.item img{width:100%;height:120px;object-fit:cover;display:block;background:#111}
|
||||||
|
.item .label{padding:8px 10px;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.cancel-row{text-align:center;margin-top:14px}
|
||||||
|
.cancel-btn{background:#3a3a4e;color:#e0e0e0;border:none;padding:8px 24px;border-radius:6px;cursor:pointer;font-size:14px}
|
||||||
|
.cancel-btn:hover{background:#4a4a5e}
|
||||||
|
</style></head><body>
|
||||||
|
<h2>Bildschirm oder Fenster ausw\u00e4hlen</h2>
|
||||||
|
<div class="grid" id="grid"></div>
|
||||||
|
<div class="cancel-row"><button class="cancel-btn" onclick="choose(null)">Abbrechen</button></div>
|
||||||
|
<script>
|
||||||
|
const sources = ${JSON.stringify(sourceData)};
|
||||||
|
const grid = document.getElementById('grid');
|
||||||
|
sources.forEach(s => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'item';
|
||||||
|
div.innerHTML = '<img src="' + s.thumbnail + '"><div class="label">' + s.name.replace(/</g,'<') + '</div>';
|
||||||
|
div.onclick = () => choose(s.id);
|
||||||
|
grid.appendChild(div);
|
||||||
|
});
|
||||||
|
function choose(id) {
|
||||||
|
document.title = 'PICK:' + (id || '');
|
||||||
|
}
|
||||||
|
</script></body></html>`;
|
||||||
|
|
||||||
|
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;
|
let isStreaming = false;
|
||||||
ipcMain.on('streaming-status', (_event, active) => { isStreaming = active; });
|
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
|
// Warn before closing if a stream is active
|
||||||
mainWindow.on('close', (event) => {
|
mainWindow.on('close', (event) => {
|
||||||
if (!isStreaming) return;
|
if (!isStreaming) return;
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,5 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
checkForUpdates: () => ipcRenderer.send('check-for-updates'),
|
checkForUpdates: () => ipcRenderer.send('check-for-updates'),
|
||||||
installUpdate: () => ipcRenderer.send('install-update'),
|
installUpdate: () => ipcRenderer.send('install-update'),
|
||||||
setStreaming: (active) => ipcRenderer.send('streaming-status', active),
|
setStreaming: (active) => ipcRenderer.send('streaming-status', active),
|
||||||
|
showNotification: (title, body) => ipcRenderer.send('show-notification', title, body),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -103,10 +103,6 @@ function handleSignalingMessage(client: WsClient, msg: any): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const password = String(msg.password || '').trim();
|
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 streamId = crypto.randomUUID();
|
||||||
const name = String(msg.name || 'Anon').slice(0, 32);
|
const name = String(msg.name || 'Anon').slice(0, 32);
|
||||||
const title = String(msg.title || 'Screen Share').slice(0, 64);
|
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
|
// Notify all other clients
|
||||||
for (const c of wsClients.values()) {
|
for (const c of wsClients.values()) {
|
||||||
if (c.id !== client.id) {
|
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)})`);
|
console.log(`[Streaming] ${name} started "${title}" (${streamId.slice(0, 8)})`);
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,9 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
setIsBroadcasting(true);
|
setIsBroadcasting(true);
|
||||||
isBroadcastingRef.current = true;
|
isBroadcastingRef.current = true;
|
||||||
setStarting(false);
|
setStarting(false);
|
||||||
|
if ((window as any).electronAPI?.showNotification) {
|
||||||
|
(window as any).electronAPI.showNotification('Stream gestartet', 'Dein Stream ist jetzt live!');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'stream_available':
|
case 'stream_available':
|
||||||
|
|
@ -173,15 +176,15 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
title: msg.title,
|
title: msg.title,
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
viewerCount: 0,
|
viewerCount: 0,
|
||||||
hasPassword: true,
|
hasPassword: !!msg.hasPassword,
|
||||||
}];
|
}];
|
||||||
});
|
});
|
||||||
// Toast notification for new stream
|
// Toast notification for new stream
|
||||||
if (Notification.permission === 'granted') {
|
const notifBody = `${msg.broadcasterName} streamt: ${msg.title}`;
|
||||||
new Notification('Neuer Stream', {
|
if ((window as any).electronAPI?.showNotification) {
|
||||||
body: `${msg.broadcasterName} streamt: ${msg.title}`,
|
(window as any).electronAPI.showNotification('Neuer Stream', notifBody);
|
||||||
icon: '/assets/icon.png',
|
} else if (Notification.permission === 'granted') {
|
||||||
});
|
new Notification('Neuer Stream', { body: notifBody, icon: '/assets/icon.png' });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -343,7 +346,6 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
// ── Start broadcasting ──
|
// ── Start broadcasting ──
|
||||||
const startBroadcast = useCallback(async () => {
|
const startBroadcast = useCallback(async () => {
|
||||||
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
|
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
|
||||||
if (!streamPassword.trim()) { setError('Passwort ist Pflicht.'); return; }
|
|
||||||
if (!navigator.mediaDevices?.getDisplayMedia) {
|
if (!navigator.mediaDevices?.getDisplayMedia) {
|
||||||
setError('Dein Browser unterstützt keine Bildschirmfreigabe.');
|
setError('Dein Browser unterstützt keine Bildschirmfreigabe.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -365,7 +367,7 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
connectWs();
|
connectWs();
|
||||||
const waitForWs = () => {
|
const waitForWs = () => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
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); }
|
} else { setTimeout(waitForWs, 100); }
|
||||||
};
|
};
|
||||||
waitForWs();
|
waitForWs();
|
||||||
|
|
@ -393,9 +395,25 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
}, [wsSend]);
|
}, [wsSend]);
|
||||||
|
|
||||||
// ── Join as viewer ──
|
// ── 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) => {
|
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(() => {
|
const submitJoinModal = useCallback(() => {
|
||||||
if (!joinModal) return;
|
if (!joinModal) return;
|
||||||
|
|
@ -587,7 +605,7 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
<input
|
<input
|
||||||
className="stream-input stream-input-password"
|
className="stream-input stream-input-password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Passwort"
|
placeholder="Passwort (optional)"
|
||||||
value={streamPassword}
|
value={streamPassword}
|
||||||
onChange={e => setStreamPassword(e.target.value)}
|
onChange={e => setStreamPassword(e.target.value)}
|
||||||
disabled={isBroadcasting}
|
disabled={isBroadcasting}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue