Files
admin acbd3b6349 feat: v1.2.0 — auto-reconnect, recording, monitor bounds, WASAPI loopback
- Auto-reconnect: stream retries with exponential backoff (2s→30s max)
  on unexpected exit; stops only when user clicks Stop
- Recording: global RecordingBar with folder picker, segment duration (min),
  per-stream checkbox, Select All; uses FFmpeg tee muxer for simultaneous
  SRT + segmented .ts file output
- Monitor capture: PowerShell System.Windows.Forms.Screen gives exact
  pixel bounds (x/y/width/height); per-monitor deviceName is now unique
  (desktop_monitor_N); gdigrab uses -offset_x/-offset_y/-video_size
- Window capture: windowTitle now explicitly propagated in handleVideoSelect
- System audio: added WASAPI Loopback option (no VB-Audio required),
  existing virtual-audio-capturer kept as fallback
- Reconnecting event forwarded to renderer; status shows "Reconnecting…"
  with attempt count in logs
- IPC: pick-folder (dialog.showOpenDialog) and open-folder (shell.openPath)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 12:10:39 +03:00

394 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
})