feat: initial release — SRT Streamer v1.0.0
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>
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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()
|
||||
})
|
||||
Reference in New Issue
Block a user