diff --git a/electron/main.js b/electron/main.js index a110440..1bf1f1b 100644 --- a/electron/main.js +++ b/electron/main.js @@ -4,38 +4,107 @@ const fs = require('fs') const ffmpegManager = require('./ffmpeg') const deviceManager = require('./devices') -// Inline tray icon as SVG → base64 DataURL (no external file needed) -function makeTrayIcon() { - const size = 16 - const svg = ` - - - - ` - const dataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` - const img = nativeImage.createFromDataURL(dataUrl) - if (process.platform === 'darwin') { - img.setTemplateImage(true) +// ─── 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 } -// Inline icon for main window (32x32) function makeWindowIcon() { - const svg = ` - - - - - - - - - - - ` - const dataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` - return nativeImage.createFromDataURL(dataUrl) + // 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 @@ -125,6 +194,29 @@ function createTray() { }) } +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() @@ -138,17 +230,17 @@ function showWindow() { function updateTrayMenu(activeCount = 0) { if (!tray) return const statusLabel = activeCount > 0 - ? `Active streams: ${activeCount}` - : 'No active streams' + ? `▶ Активных потоков: ${activeCount}` + : '⬛ Нет активных потоков' const contextMenu = Menu.buildFromTemplate([ { label: 'SRT Streamer', enabled: false }, { label: statusLabel, enabled: false }, { type: 'separator' }, - { label: 'Show', click: () => showWindow() }, + { label: '📂 Открыть', click: () => showWindow() }, { type: 'separator' }, { - label: 'Stop All Streams', + label: '⏹ Остановить все потоки', enabled: activeCount > 0, click: () => { ffmpegManager.stopAllStreams() @@ -156,27 +248,7 @@ function updateTrayMenu(activeCount = 0) { } }, { type: 'separator' }, - { - label: 'Quit', - click: () => { - const activeCount = ffmpegManager.getActiveCount() - if (activeCount > 0) { - const choice = dialog.showMessageBoxSync({ - type: 'question', - buttons: ['Quit', 'Cancel'], - defaultId: 1, - cancelId: 1, - title: 'Quit SRT Streamer', - message: `${activeCount} stream(s) are active.`, - detail: 'All running streams will be stopped. Are you sure you want to quit?' - }) - if (choice !== 0) return - } - isQuitting = true - ffmpegManager.stopAllStreams() - app.quit() - } - } + { label: '✖ Выход', click: () => confirmAndQuit() } ]) tray.setContextMenu(contextMenu) }