1f7dbc2a7d
Cross-platform Electron + React + FFmpeg desktop app for sending multiple SRT streams simultaneously. Features: - Multiple simultaneous SRT output streams - Video sources: desktop, window capture, cameras, capture cards - Audio sources: microphones, system loopback, sound cards - H.264 encoding with HW acceleration (NVENC/QSV/AMF/VideoToolbox) - SRT modes: caller / listener / rendezvous - Frame profile presets (4K, 1080p, 720p, 480p, 360p) - Tolbek SRT receiver with configurable mode - System tray: minimize-to-tray, exit confirmation dialog - Portable build via electron-builder Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
307 lines
7.8 KiB
JavaScript
307 lines
7.8 KiB
JavaScript
const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage, dialog } = require('electron')
|
|
const path = require('path')
|
|
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 16 16">
|
|
<rect width="16" height="16" rx="3" fill="#1a1a2e"/>
|
|
<polygon points="4,2 13,8 4,14" fill="#4f8ef7"/>
|
|
<circle cx="13" cy="3" r="2.5" fill="#ef4444"/>
|
|
</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)
|
|
}
|
|
return img
|
|
}
|
|
|
|
// Inline icon for main window (32x32)
|
|
function makeWindowIcon() {
|
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
|
<defs>
|
|
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" stop-color="#1a1a2e"/>
|
|
<stop offset="100%" stop-color="#16213e"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<rect width="256" height="256" rx="48" fill="url(#g)"/>
|
|
<polygon points="72,48 200,128 72,208" fill="#4f8ef7"/>
|
|
<circle cx="200" cy="56" r="32" fill="#ef4444"/>
|
|
<circle cx="200" cy="56" r="18" fill="#fff"/>
|
|
</svg>`
|
|
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
|
|
|
|
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 } }
|
|
}
|
|
|
|
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 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
|
|
? `Active streams: ${activeCount}`
|
|
: 'No active streams'
|
|
|
|
const contextMenu = Menu.buildFromTemplate([
|
|
{ label: 'SRT Streamer', enabled: false },
|
|
{ label: statusLabel, enabled: false },
|
|
{ type: 'separator' },
|
|
{ label: 'Show', click: () => showWindow() },
|
|
{ type: 'separator' },
|
|
{
|
|
label: 'Stop All Streams',
|
|
enabled: activeCount > 0,
|
|
click: () => {
|
|
ffmpegManager.stopAllStreams()
|
|
mainWindow && mainWindow.webContents.send('all-streams-stopped')
|
|
}
|
|
},
|
|
{ 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()
|
|
}
|
|
}
|
|
])
|
|
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('minimize-window', () => {
|
|
mainWindow && mainWindow.minimize()
|
|
})
|
|
|
|
ipcMain.handle('hide-window', () => {
|
|
mainWindow && mainWindow.hide()
|
|
})
|
|
|
|
ipcMain.on('update-tray-count', (_, count) => {
|
|
updateTrayMenu(count)
|
|
})
|
|
|
|
// Forward FFmpeg log 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)
|
|
const active = ffmpegManager.getActiveCount()
|
|
updateTrayMenu(active)
|
|
})
|
|
|
|
ffmpegManager.on('ended', (data) => {
|
|
mainWindow && mainWindow.webContents.send('stream-ended', data)
|
|
const active = ffmpegManager.getActiveCount()
|
|
updateTrayMenu(active)
|
|
})
|
|
|
|
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()
|
|
})
|