const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage, dialog, shell } = require('electron') const path = require('path') const fs = require('fs') const ffmpegManager = require('./ffmpeg') const deviceManager = require('./devices') // ─── Icon helpers ────────────────────────────────────────────────────────────── // Draw a pixel into an RGBA buffer function setPixel(buf, x, y, W, r, g, b, a = 255) { if (x < 0 || y < 0 || x >= W || y >= W) return const i = (y * W + x) * 4 buf[i] = r; buf[i+1] = g; buf[i+2] = b; buf[i+3] = a } // Anti-aliased circle helper: returns alpha 0-255 for a pixel at (px,py) // relative to circle center (cx,cy) with radius r function circleAlpha(px, py, cx, cy, r) { const d = Math.sqrt((px - cx) ** 2 + (py - cy) ** 2) if (d > r + 0.7) return 0 if (d < r - 0.7) return 255 return Math.round((r + 0.7 - d) / 1.4 * 255) } // Generate tray icon as raw RGBA buffer — works on all platforms // SVG DataURL does NOT work on Windows Electron tray, RGBA buffer does. function makeTrayIconBuffer(size = 16) { const buf = Buffer.alloc(size * size * 4, 0) for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { // Rounded background const cx = size / 2 - 0.5, cy = size / 2 - 0.5, r = size / 2 - 0.5 const dx = x - cx, dy = y - cy if (dx * dx + dy * dy > r * r) continue // outside circle → transparent // Dark background setPixel(buf, x, y, size, 20, 20, 38, 255) // Play triangle: points (3, 2), (3, 13), (12, 7.5) const tL = 3, tT = 2, tB = size - 2, tR = size - 3 const midY = (tT + tB) / 2 // A point is inside the triangle if it satisfies all three half-plane tests const cross1 = (tR - tL) * (y - tT) - (midY - tT) * (x - tL) // left-top to right const cross2 = (tL - tR) * (y - midY) - (tT - midY) * (x - tR) // right to left-bot const cross3 = (0) * (y - tB) - (tT - tB) * (x - tL) // left edge // Simplified inside-triangle check const inTri = x >= tL && x <= tR && y >= (tT + (midY - tT) / (tR - tL) * (x - tL) - 0.5) && y <= (tB - (tB - midY) / (tR - tL) * (x - tL) + 0.5) if (inTri) { setPixel(buf, x, y, size, 79, 142, 247, 255) // accent blue continue } // Red live dot — top right quadrant const dotCX = size * 0.78, dotCY = size * 0.22, dotR = size * 0.18 const da = circleAlpha(x, y, dotCX, dotCY, dotR) if (da > 0) { // Inner white const innerA = circleAlpha(x, y, dotCX, dotCY, dotR * 0.5) if (innerA > 128) setPixel(buf, x, y, size, 255, 255, 255, innerA) else setPixel(buf, x, y, size, 239, 68, 68, da) } } } return buf } function makeTrayIcon() { // 1. Try to load pre-generated file (faster, better quality) const pngPaths = [ path.join(__dirname, '../assets/tray-icon.png'), path.join(process.resourcesPath || '', '../assets/tray-icon.png') ] for (const p of pngPaths) { if (fs.existsSync(p)) { try { const img = nativeImage.createFromPath(p) if (!img.isEmpty()) { if (process.platform === 'darwin') img.setTemplateImage(true) return img } } catch {} } } // 2. Fallback: create from raw RGBA buffer (always works, no file needed) const size = process.platform === 'darwin' ? 22 : 16 const buf = makeTrayIconBuffer(size) const img = nativeImage.createFromBuffer(buf, { width: size, height: size }) if (process.platform === 'darwin') img.setTemplateImage(true) return img } function makeWindowIcon() { // Use pre-generated icon.png if available const pngPath = path.join(__dirname, '../assets/icon.png') if (fs.existsSync(pngPath)) { try { const img = nativeImage.createFromPath(pngPath) if (!img.isEmpty()) return img } catch {} } // Fallback: 32×32 RGBA buffer const size = 32 const buf = makeTrayIconBuffer(size) return nativeImage.createFromBuffer(buf, { width: size, height: size }) } const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged let mainWindow = null let tray = null let isQuitting = false // Persistent config storage const configPath = path.join(app.getPath('userData'), 'config.json') function loadConfig() { try { if (fs.existsSync(configPath)) { return JSON.parse(fs.readFileSync(configPath, 'utf8')) } } catch (e) { console.error('Failed to load config:', e) } return { streams: [], tolbek: { enabled: false, port: 5000, latency: 200 }, recording: { folder: '', segmentMinutes: 60 } } } function saveConfig(config) { try { fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8') } catch (e) { console.error('Failed to save config:', e) } } function createWindow() { mainWindow = new BrowserWindow({ width: 1100, height: 750, minWidth: 800, minHeight: 600, backgroundColor: '#1a1a2e', frame: false, titleBarStyle: 'hidden', titleBarOverlay: { color: '#16213e', symbolColor: '#e0e0e0', height: 36 }, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false }, icon: makeWindowIcon() }) if (isDev) { mainWindow.loadURL('http://localhost:5173') mainWindow.webContents.openDevTools({ mode: 'detach' }) } else { mainWindow.loadFile(path.join(__dirname, '../dist/index.html')) } // Hide to tray on close instead of quitting mainWindow.on('close', (event) => { if (!isQuitting) { event.preventDefault() mainWindow.hide() if (process.platform === 'darwin') { app.dock.hide() } } }) } function createTray() { const trayImage = makeTrayIcon() tray = new Tray(trayImage) tray.setToolTip('SRT Streamer') updateTrayMenu() tray.on('click', () => { if (mainWindow) { if (mainWindow.isVisible()) { mainWindow.focus() } else { showWindow() } } }) } function confirmAndQuit() { const activeCount = ffmpegManager.getActiveCount() const detail = activeCount > 0 ? `Активных потоков: ${activeCount}. Все будут остановлены.` : 'Приложение будет закрыто.' const choice = dialog.showMessageBoxSync(mainWindow || undefined, { type: 'question', buttons: ['Выйти', 'Отмена'], defaultId: 1, cancelId: 1, title: 'SRT Streamer — выход', message: 'Вы уверены, что хотите выйти?', detail }) if (choice === 0) { isQuitting = true ffmpegManager.stopAllStreams() app.quit() } } function showWindow() { if (mainWindow) { mainWindow.show() mainWindow.focus() if (process.platform === 'darwin') { app.dock.show() } } } function updateTrayMenu(activeCount = 0) { if (!tray) return const statusLabel = activeCount > 0 ? `▶ Активных потоков: ${activeCount}` : '⬛ Нет активных потоков' const contextMenu = Menu.buildFromTemplate([ { label: 'SRT Streamer', enabled: false }, { label: statusLabel, enabled: false }, { type: 'separator' }, { label: '📂 Открыть', click: () => showWindow() }, { type: 'separator' }, { label: '⏹ Остановить все потоки', enabled: activeCount > 0, click: () => { ffmpegManager.stopAllStreams() mainWindow && mainWindow.webContents.send('all-streams-stopped') } }, { type: 'separator' }, { label: '✖ Выход', click: () => confirmAndQuit() } ]) tray.setContextMenu(contextMenu) } // IPC Handlers ipcMain.handle('get-config', () => loadConfig()) ipcMain.handle('save-config', (_, config) => { saveConfig(config); return true }) ipcMain.handle('get-devices', async () => { try { return await deviceManager.getDevices() } catch (e) { console.error('Device enumeration error:', e) return { video: [], audio: [] } } }) ipcMain.handle('get-windows', async () => { try { return await deviceManager.getWindows() } catch (e) { return [] } }) ipcMain.handle('start-stream', async (_, streamConfig) => { try { const result = await ffmpegManager.startStream(streamConfig) const active = ffmpegManager.getActiveCount() updateTrayMenu(active) mainWindow && mainWindow.webContents.send('stream-status', { id: streamConfig.id, status: 'running' }) return { success: true } } catch (e) { return { success: false, error: e.message } } }) ipcMain.handle('stop-stream', async (_, streamId) => { try { ffmpegManager.stopStream(streamId) const active = ffmpegManager.getActiveCount() updateTrayMenu(active) return { success: true } } catch (e) { return { success: false, error: e.message } } }) ipcMain.handle('start-tolbek', async (_, config) => { try { const result = await ffmpegManager.startTolbek(config) return { success: true } } catch (e) { return { success: false, error: e.message } } }) ipcMain.handle('stop-tolbek', async () => { try { ffmpegManager.stopTolbek() return { success: true } } catch (e) { return { success: false, error: e.message } } }) ipcMain.handle('get-active-streams', () => { return ffmpegManager.getActiveStreams() }) ipcMain.handle('pick-folder', async () => { const result = await dialog.showOpenDialog(mainWindow || undefined, { title: 'Выберите папку для записи', properties: ['openDirectory', 'createDirectory'] }) if (result.canceled || !result.filePaths.length) return null return result.filePaths[0] }) ipcMain.handle('open-folder', async (_, folderPath) => { try { await shell.openPath(folderPath) } catch {} }) ipcMain.handle('minimize-window', () => { mainWindow && mainWindow.minimize() }) ipcMain.handle('hide-window', () => { mainWindow && mainWindow.hide() }) ipcMain.on('update-tray-count', (_, count) => { updateTrayMenu(count) }) // Forward FFmpeg events to renderer ffmpegManager.on('log', (data) => { mainWindow && mainWindow.webContents.send('stream-log', data) }) ffmpegManager.on('error', (data) => { mainWindow && mainWindow.webContents.send('stream-error', data) updateTrayMenu(ffmpegManager.getActiveCount()) }) ffmpegManager.on('ended', (data) => { mainWindow && mainWindow.webContents.send('stream-ended', data) updateTrayMenu(ffmpegManager.getActiveCount()) }) ffmpegManager.on('reconnecting', (data) => { mainWindow && mainWindow.webContents.send('stream-reconnecting', data) }) app.whenReady().then(() => { createWindow() createTray() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() } else { showWindow() } }) }) app.on('window-all-closed', (e) => { // Keep app running in tray if (process.platform !== 'darwin') { e.preventDefault() } }) app.on('before-quit', () => { isQuitting = true ffmpegManager.stopAllStreams() })