fix: tray icon invisible + exit without confirmation
Tray icon: - Replaced SVG DataURL approach (broken on Windows) with nativeImage.createFromBuffer() using raw RGBA pixel data - Draws rounded background + play triangle + red dot pixel-by-pixel - Falls back to pre-generated tray-icon.png if present (build output) - makeWindowIcon() also loads from assets/icon.png when available Exit confirmation: - confirmAndQuit() always shows dialog — previously only asked when activeCount > 0, so quit with no streams skipped the dialog - Dialog shows active stream count in detail text - Tray menu relabeled in Russian with emoji markers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+124
-52
@@ -4,38 +4,107 @@ const fs = require('fs')
|
|||||||
const ffmpegManager = require('./ffmpeg')
|
const ffmpegManager = require('./ffmpeg')
|
||||||
const deviceManager = require('./devices')
|
const deviceManager = require('./devices')
|
||||||
|
|
||||||
// Inline tray icon as SVG → base64 DataURL (no external file needed)
|
// ─── Icon helpers ──────────────────────────────────────────────────────────────
|
||||||
function makeTrayIcon() {
|
// Draw a pixel into an RGBA buffer
|
||||||
const size = 16
|
function setPixel(buf, x, y, W, r, g, b, a = 255) {
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 16 16">
|
if (x < 0 || y < 0 || x >= W || y >= W) return
|
||||||
<rect width="16" height="16" rx="3" fill="#1a1a2e"/>
|
const i = (y * W + x) * 4
|
||||||
<polygon points="4,2 13,8 4,14" fill="#4f8ef7"/>
|
buf[i] = r; buf[i+1] = g; buf[i+2] = b; buf[i+3] = a
|
||||||
<circle cx="13" cy="3" r="2.5" fill="#ef4444"/>
|
}
|
||||||
</svg>`
|
|
||||||
const dataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
// Anti-aliased circle helper: returns alpha 0-255 for a pixel at (px,py)
|
||||||
const img = nativeImage.createFromDataURL(dataUrl)
|
// relative to circle center (cx,cy) with radius r
|
||||||
if (process.platform === 'darwin') {
|
function circleAlpha(px, py, cx, cy, r) {
|
||||||
img.setTemplateImage(true)
|
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
|
return img
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline icon for main window (32x32)
|
|
||||||
function makeWindowIcon() {
|
function makeWindowIcon() {
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
// Use pre-generated icon.png if available
|
||||||
<defs>
|
const pngPath = path.join(__dirname, '../assets/icon.png')
|
||||||
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
if (fs.existsSync(pngPath)) {
|
||||||
<stop offset="0%" stop-color="#1a1a2e"/>
|
try {
|
||||||
<stop offset="100%" stop-color="#16213e"/>
|
const img = nativeImage.createFromPath(pngPath)
|
||||||
</linearGradient>
|
if (!img.isEmpty()) return img
|
||||||
</defs>
|
} catch {}
|
||||||
<rect width="256" height="256" rx="48" fill="url(#g)"/>
|
}
|
||||||
<polygon points="72,48 200,128 72,208" fill="#4f8ef7"/>
|
// Fallback: 32×32 RGBA buffer
|
||||||
<circle cx="200" cy="56" r="32" fill="#ef4444"/>
|
const size = 32
|
||||||
<circle cx="200" cy="56" r="18" fill="#fff"/>
|
const buf = makeTrayIconBuffer(size)
|
||||||
</svg>`
|
return nativeImage.createFromBuffer(buf, { width: size, height: size })
|
||||||
const dataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
|
||||||
return nativeImage.createFromDataURL(dataUrl)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
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() {
|
function showWindow() {
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
mainWindow.show()
|
mainWindow.show()
|
||||||
@@ -138,17 +230,17 @@ function showWindow() {
|
|||||||
function updateTrayMenu(activeCount = 0) {
|
function updateTrayMenu(activeCount = 0) {
|
||||||
if (!tray) return
|
if (!tray) return
|
||||||
const statusLabel = activeCount > 0
|
const statusLabel = activeCount > 0
|
||||||
? `Active streams: ${activeCount}`
|
? `▶ Активных потоков: ${activeCount}`
|
||||||
: 'No active streams'
|
: '⬛ Нет активных потоков'
|
||||||
|
|
||||||
const contextMenu = Menu.buildFromTemplate([
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
{ label: 'SRT Streamer', enabled: false },
|
{ label: 'SRT Streamer', enabled: false },
|
||||||
{ label: statusLabel, enabled: false },
|
{ label: statusLabel, enabled: false },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ label: 'Show', click: () => showWindow() },
|
{ label: '📂 Открыть', click: () => showWindow() },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Stop All Streams',
|
label: '⏹ Остановить все потоки',
|
||||||
enabled: activeCount > 0,
|
enabled: activeCount > 0,
|
||||||
click: () => {
|
click: () => {
|
||||||
ffmpegManager.stopAllStreams()
|
ffmpegManager.stopAllStreams()
|
||||||
@@ -156,27 +248,7 @@ function updateTrayMenu(activeCount = 0) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{ label: '✖ Выход', click: () => confirmAndQuit() }
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
])
|
||||||
tray.setContextMenu(contextMenu)
|
tray.setContextMenu(contextMenu)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user