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,289 @@
|
||||
const { spawn, exec } = require('child_process')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
let ffmpegPath = null
|
||||
|
||||
function getFfmpegPath() {
|
||||
if (ffmpegPath) return ffmpegPath
|
||||
|
||||
const resourcesPath = process.resourcesPath || path.join(__dirname, '..')
|
||||
const candidates = [
|
||||
path.join(resourcesPath, 'ffmpeg-bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
|
||||
path.join(__dirname, '..', 'ffmpeg-bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
|
||||
]
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) {
|
||||
ffmpegPath = p
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
ffmpegPath = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
return ffmpegPath
|
||||
}
|
||||
|
||||
function runCommand(cmd, args) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
proc.stdout.on('data', d => stdout += d)
|
||||
proc.stderr.on('data', d => stderr += d)
|
||||
proc.on('close', () => resolve({ stdout, stderr }))
|
||||
proc.on('error', () => resolve({ stdout: '', stderr: '' }))
|
||||
// Timeout
|
||||
setTimeout(() => { proc.kill(); resolve({ stdout, stderr }) }, 10000)
|
||||
})
|
||||
}
|
||||
|
||||
async function getWindowsDevices() {
|
||||
const ffmpeg = getFfmpegPath()
|
||||
const { stderr } = await runCommand(ffmpeg, [
|
||||
'-list_devices', 'true',
|
||||
'-f', 'dshow',
|
||||
'-i', 'dummy'
|
||||
])
|
||||
|
||||
const video = []
|
||||
const audio = []
|
||||
|
||||
const lines = stderr.split('\n')
|
||||
let section = null
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('"') && line.includes('(video)')) {
|
||||
section = 'video'
|
||||
const match = line.match(/"([^"]+)"\s*\(video\)/)
|
||||
if (match) {
|
||||
video.push({ name: match[1], deviceName: match[1], type: 'device' })
|
||||
}
|
||||
} else if (line.includes('"') && line.includes('(audio)')) {
|
||||
section = 'audio'
|
||||
const match = line.match(/"([^"]+)"\s*\(audio\)/)
|
||||
if (match) {
|
||||
audio.push({ name: match[1], deviceName: match[1], type: 'device' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add system audio loopback devices (WASAPI)
|
||||
// These appear as "virtual-audio-capturer" or similar dshow devices
|
||||
// Also detect via registry / wmic
|
||||
audio.push({
|
||||
name: 'System Audio (Loopback)',
|
||||
deviceName: 'virtual-audio-capturer',
|
||||
type: 'system',
|
||||
description: 'Capture all system audio output'
|
||||
})
|
||||
|
||||
// Add screen capture options
|
||||
const screenDevices = [
|
||||
{ name: 'Desktop (Full Screen)', type: 'desktop', deviceName: 'desktop' },
|
||||
]
|
||||
|
||||
// Try to enumerate monitors
|
||||
const monitors = await getWindowsMonitors()
|
||||
monitors.forEach((m, i) => {
|
||||
screenDevices.push({
|
||||
name: `Monitor ${i + 1}${m.label ? ': ' + m.label : ''}`,
|
||||
type: 'desktop',
|
||||
deviceName: `desktop`,
|
||||
monitorIndex: i
|
||||
})
|
||||
})
|
||||
|
||||
return { video: [...screenDevices, ...video], audio }
|
||||
}
|
||||
|
||||
async function getWindowsMonitors() {
|
||||
return new Promise((resolve) => {
|
||||
exec('wmic desktopmonitor get Name,ScreenHeight,ScreenWidth /format:list', (err, stdout) => {
|
||||
if (err) { resolve([]); return }
|
||||
const monitors = []
|
||||
const blocks = stdout.split('\r\n\r\n').filter(b => b.trim())
|
||||
for (const block of blocks) {
|
||||
const nameMatch = block.match(/Name=(.+)/)
|
||||
if (nameMatch) monitors.push({ label: nameMatch[1].trim() })
|
||||
}
|
||||
resolve(monitors)
|
||||
})
|
||||
setTimeout(() => resolve([]), 5000)
|
||||
})
|
||||
}
|
||||
|
||||
async function getMacDevices() {
|
||||
const ffmpeg = getFfmpegPath()
|
||||
const { stderr } = await runCommand(ffmpeg, [
|
||||
'-f', 'avfoundation',
|
||||
'-list_devices', 'true',
|
||||
'-i', ''
|
||||
])
|
||||
|
||||
const video = []
|
||||
const audio = []
|
||||
|
||||
const lines = stderr.split('\n')
|
||||
let section = null
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('AVFoundation video devices')) {
|
||||
section = 'video'
|
||||
continue
|
||||
}
|
||||
if (line.includes('AVFoundation audio devices')) {
|
||||
section = 'audio'
|
||||
continue
|
||||
}
|
||||
|
||||
const match = line.match(/\[(\d+)\]\s+(.+)/)
|
||||
if (match) {
|
||||
const index = match[1]
|
||||
const name = match[2].trim()
|
||||
if (section === 'video') {
|
||||
const isScreen = name.toLowerCase().includes('screen') || name.toLowerCase().includes('capture')
|
||||
video.push({
|
||||
name,
|
||||
deviceIndex: index,
|
||||
type: isScreen ? 'desktop' : 'device'
|
||||
})
|
||||
} else if (section === 'audio') {
|
||||
audio.push({
|
||||
name,
|
||||
deviceIndex: index,
|
||||
type: 'device'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { video, audio }
|
||||
}
|
||||
|
||||
async function getLinuxDevices() {
|
||||
const video = []
|
||||
const audio = []
|
||||
|
||||
// V4L2 devices
|
||||
try {
|
||||
const v4l2Devices = await new Promise((resolve) => {
|
||||
exec('ls /dev/video* 2>/dev/null', (err, stdout) => {
|
||||
if (err) { resolve([]); return }
|
||||
resolve(stdout.trim().split('\n').filter(Boolean))
|
||||
})
|
||||
})
|
||||
|
||||
for (const dev of v4l2Devices) {
|
||||
const { stdout } = await runCommand('v4l2-ctl', ['--device', dev, '--info'])
|
||||
const nameMatch = stdout.match(/Card type\s*:\s*(.+)/)
|
||||
video.push({
|
||||
name: nameMatch ? nameMatch[1].trim() : dev,
|
||||
devicePath: dev,
|
||||
type: 'device'
|
||||
})
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Add screen capture
|
||||
const display = process.env.DISPLAY || ':0'
|
||||
video.unshift({ name: `Desktop (${display})`, type: 'desktop', devicePath: display })
|
||||
|
||||
// PulseAudio devices
|
||||
try {
|
||||
const { stdout: paList } = await runCommand('pactl', ['list', 'short', 'sources'])
|
||||
const lines = paList.split('\n').filter(Boolean)
|
||||
for (const line of lines) {
|
||||
const parts = line.split('\t')
|
||||
if (parts.length >= 2) {
|
||||
const deviceName = parts[1]
|
||||
const isMonitor = deviceName.includes('.monitor')
|
||||
audio.push({
|
||||
name: deviceName,
|
||||
deviceName,
|
||||
type: isMonitor ? 'system' : 'device',
|
||||
description: isMonitor ? 'System audio (loopback)' : ''
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
audio.push({ name: 'default', deviceName: 'default', type: 'device' })
|
||||
}
|
||||
|
||||
return { video, audio }
|
||||
}
|
||||
|
||||
async function getDevices() {
|
||||
let result
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
result = await getWindowsDevices()
|
||||
} else if (process.platform === 'darwin') {
|
||||
result = await getMacDevices()
|
||||
} else {
|
||||
result = await getLinuxDevices()
|
||||
}
|
||||
|
||||
// Always add "None" options
|
||||
result.video.unshift({ name: '— No Video —', type: 'none', deviceName: 'none' })
|
||||
result.audio.unshift({ name: '— No Audio —', type: 'none', deviceName: 'none' })
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function getWindows() {
|
||||
if (process.platform === 'win32') {
|
||||
return getWindowsWindowsList()
|
||||
} else if (process.platform === 'darwin') {
|
||||
return getMacWindowsList()
|
||||
} else {
|
||||
return getLinuxWindowsList()
|
||||
}
|
||||
}
|
||||
|
||||
function getWindowsWindowsList() {
|
||||
return new Promise((resolve) => {
|
||||
const script = `
|
||||
$windows = Get-Process | Where-Object {$_.MainWindowTitle -ne ""} | Select-Object ProcessName, MainWindowTitle
|
||||
$windows | ForEach-Object { Write-Output "$($_.MainWindowTitle)" }
|
||||
`
|
||||
exec(`powershell -Command "${script.replace(/\n/g, ' ')}"`, (err, stdout) => {
|
||||
if (err) { resolve([]); return }
|
||||
const windows = stdout.trim().split('\n')
|
||||
.map(w => w.trim())
|
||||
.filter(Boolean)
|
||||
.map(title => ({ title, type: 'window' }))
|
||||
resolve(windows)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getMacWindowsList() {
|
||||
return new Promise((resolve) => {
|
||||
exec(`osascript -e 'tell application "System Events" to get name of every process whose has UI elements is true'`, (err, stdout) => {
|
||||
if (err) { resolve([]); return }
|
||||
const apps = stdout.trim().split(', ')
|
||||
.filter(Boolean)
|
||||
.map(title => ({ title, type: 'window' }))
|
||||
resolve(apps)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getLinuxWindowsList() {
|
||||
return new Promise((resolve) => {
|
||||
exec('wmctrl -l 2>/dev/null', (err, stdout) => {
|
||||
if (err) { resolve([]); return }
|
||||
const windows = stdout.trim().split('\n')
|
||||
.map(line => {
|
||||
const parts = line.split(/\s+/)
|
||||
const title = parts.slice(3).join(' ')
|
||||
return { title, type: 'window' }
|
||||
})
|
||||
.filter(w => w.title)
|
||||
resolve(windows)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { getDevices, getWindows }
|
||||
@@ -0,0 +1,370 @@
|
||||
const { EventEmitter } = require('events')
|
||||
const { spawn } = require('child_process')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const { app } = require('electron')
|
||||
|
||||
class FFmpegManager extends EventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
this.streams = new Map() // id -> { process, config }
|
||||
this.tolbekProcess = null
|
||||
this.ffmpegPath = this._resolveFfmpegPath()
|
||||
}
|
||||
|
||||
_resolveFfmpegPath() {
|
||||
// 1. Check bundled ffmpeg in app resources
|
||||
const resourcesPath = process.resourcesPath || path.join(__dirname, '..')
|
||||
const candidates = [
|
||||
path.join(resourcesPath, 'ffmpeg-bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
|
||||
path.join(__dirname, '..', 'ffmpeg-bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
|
||||
]
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) {
|
||||
console.log('[FFmpeg] Using bundled:', p)
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to system ffmpeg
|
||||
console.log('[FFmpeg] Using system ffmpeg')
|
||||
return process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
}
|
||||
|
||||
// Build FFmpeg args for a stream config
|
||||
_buildStreamArgs(config) {
|
||||
const args = []
|
||||
const platform = process.platform
|
||||
|
||||
// === VIDEO INPUT ===
|
||||
const { videoSource, audioSource } = config
|
||||
|
||||
if (videoSource.type === 'desktop') {
|
||||
// Screen capture
|
||||
if (platform === 'win32') {
|
||||
args.push('-f', 'gdigrab')
|
||||
const hideCursor = videoSource.hideCursor ? '0' : '1'
|
||||
args.push('-draw_mouse', hideCursor)
|
||||
args.push('-framerate', String(config.framerate || 30))
|
||||
args.push('-i', 'desktop')
|
||||
} else if (platform === 'darwin') {
|
||||
args.push('-f', 'avfoundation')
|
||||
args.push('-framerate', String(config.framerate || 30))
|
||||
args.push('-capture_cursor', videoSource.hideCursor ? '0' : '1')
|
||||
args.push('-i', `${videoSource.screenIndex || '1'}:none`)
|
||||
} else {
|
||||
args.push('-f', 'x11grab')
|
||||
const display = process.env.DISPLAY || ':0'
|
||||
args.push('-framerate', String(config.framerate || 30))
|
||||
if (videoSource.hideCursor) args.push('-draw_mouse', '0')
|
||||
args.push('-i', `${display}`)
|
||||
}
|
||||
} else if (videoSource.type === 'window') {
|
||||
// Window capture
|
||||
if (platform === 'win32') {
|
||||
args.push('-f', 'gdigrab')
|
||||
args.push('-draw_mouse', videoSource.hideCursor ? '0' : '1')
|
||||
args.push('-framerate', String(config.framerate || 30))
|
||||
args.push('-i', `title=${videoSource.windowTitle}`)
|
||||
} else if (platform === 'darwin') {
|
||||
// macOS window capture via avfoundation index
|
||||
args.push('-f', 'avfoundation')
|
||||
args.push('-framerate', String(config.framerate || 30))
|
||||
args.push('-i', `${videoSource.deviceIndex || '1'}:none`)
|
||||
} else {
|
||||
args.push('-f', 'x11grab')
|
||||
args.push('-framerate', String(config.framerate || 30))
|
||||
args.push('-i', process.env.DISPLAY || ':0')
|
||||
}
|
||||
} else if (videoSource.type === 'device') {
|
||||
// Camera or capture card
|
||||
if (platform === 'win32') {
|
||||
args.push('-f', 'dshow')
|
||||
args.push('-framerate', String(config.framerate || 30))
|
||||
if (config.resolution) args.push('-video_size', config.resolution)
|
||||
args.push('-i', `video=${videoSource.deviceName}`)
|
||||
} else if (platform === 'darwin') {
|
||||
args.push('-f', 'avfoundation')
|
||||
args.push('-framerate', String(config.framerate || 30))
|
||||
if (config.resolution) args.push('-video_size', config.resolution)
|
||||
args.push('-i', `${videoSource.deviceIndex}:none`)
|
||||
} else {
|
||||
args.push('-f', 'v4l2')
|
||||
args.push('-framerate', String(config.framerate || 30))
|
||||
if (config.resolution) args.push('-video_size', config.resolution)
|
||||
args.push('-i', videoSource.devicePath || '/dev/video0')
|
||||
}
|
||||
} else if (videoSource.type === 'none') {
|
||||
// No video - generate black frame
|
||||
args.push('-f', 'lavfi', '-i', 'color=black:640x480:rate=25')
|
||||
}
|
||||
|
||||
// === AUDIO INPUT ===
|
||||
if (audioSource && audioSource.type !== 'none') {
|
||||
if (platform === 'win32') {
|
||||
if (audioSource.type === 'system') {
|
||||
// System audio loopback via wasapi
|
||||
args.push('-f', 'dshow')
|
||||
args.push('-i', `audio=${audioSource.deviceName}`)
|
||||
} else {
|
||||
args.push('-f', 'dshow')
|
||||
args.push('-i', `audio=${audioSource.deviceName}`)
|
||||
}
|
||||
} else if (platform === 'darwin') {
|
||||
args.push('-f', 'avfoundation')
|
||||
args.push('-i', `:${audioSource.deviceIndex || '0'}`)
|
||||
} else {
|
||||
if (audioSource.type === 'system') {
|
||||
// PulseAudio monitor for system audio
|
||||
args.push('-f', 'pulse')
|
||||
args.push('-i', audioSource.deviceName || 'default.monitor')
|
||||
} else {
|
||||
args.push('-f', 'alsa')
|
||||
args.push('-i', audioSource.deviceName || 'default')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === VIDEO ENCODING ===
|
||||
const videoCodec = this._selectVideoCodec(config.hwAccel)
|
||||
args.push('-c:v', videoCodec)
|
||||
|
||||
// Codec-specific options
|
||||
if (videoCodec === 'h264_nvenc') {
|
||||
args.push('-preset', 'p4', '-tune', 'ull', '-rc', 'cbr')
|
||||
} else if (videoCodec === 'h264_qsv') {
|
||||
args.push('-preset', 'faster')
|
||||
} else if (videoCodec === 'h264_amf') {
|
||||
args.push('-quality', 'speed', '-rc', 'cbr')
|
||||
} else if (videoCodec === 'h264_videotoolbox') {
|
||||
args.push('-realtime', '1')
|
||||
} else {
|
||||
// libx264 software
|
||||
args.push('-preset', 'ultrafast', '-tune', 'zerolatency')
|
||||
}
|
||||
|
||||
const videoBitrate = config.videoBitrate || '2000k'
|
||||
args.push('-b:v', videoBitrate)
|
||||
args.push('-maxrate', videoBitrate)
|
||||
args.push('-bufsize', videoBitrate)
|
||||
|
||||
if (config.resolution) {
|
||||
args.push('-vf', `scale=${config.resolution.replace('x', ':')}`)
|
||||
}
|
||||
|
||||
args.push('-g', String((config.framerate || 30) * 2)) // keyframe interval
|
||||
|
||||
// === AUDIO ENCODING ===
|
||||
if (audioSource && audioSource.type !== 'none') {
|
||||
args.push('-c:a', 'aac')
|
||||
args.push('-b:a', config.audioBitrate || '128k')
|
||||
args.push('-ar', '44100')
|
||||
} else {
|
||||
args.push('-an')
|
||||
}
|
||||
|
||||
// === SRT OUTPUT ===
|
||||
const { serverAddress, port, srtMode, latency } = config
|
||||
const address = serverAddress.replace(/^(srt:\/\/|rtmp:\/\/|http:\/\/|https:\/\/)/, '')
|
||||
const lat = latency || 200
|
||||
const mode = srtMode || 'caller'
|
||||
|
||||
let srtUrl = `srt://${address}:${port}?mode=${mode}&latency=${lat * 1000}&pkt_size=1316`
|
||||
|
||||
args.push('-f', 'mpegts')
|
||||
args.push(srtUrl)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
_selectVideoCodec(hwAccel) {
|
||||
if (!hwAccel || hwAccel === 'none') return 'libx264'
|
||||
if (hwAccel === 'auto') {
|
||||
// Will be detected at runtime; return in preference order
|
||||
if (process.platform === 'darwin') return 'h264_videotoolbox'
|
||||
return 'h264_nvenc' // Will fallback in startStream if unavailable
|
||||
}
|
||||
const codecMap = {
|
||||
'nvenc': 'h264_nvenc',
|
||||
'qsv': 'h264_qsv',
|
||||
'amf': 'h264_amf',
|
||||
'videotoolbox': 'h264_videotoolbox',
|
||||
'vaapi': 'h264_vaapi',
|
||||
'software': 'libx264'
|
||||
}
|
||||
return codecMap[hwAccel] || 'libx264'
|
||||
}
|
||||
|
||||
async startStream(config) {
|
||||
const { id } = config
|
||||
if (this.streams.has(id)) {
|
||||
this.stopStream(id)
|
||||
}
|
||||
|
||||
const args = this._buildStreamArgs(config)
|
||||
console.log(`[FFmpeg] Starting stream ${id}:`, this.ffmpegPath, args.join(' '))
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(this.ffmpegPath, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
let started = false
|
||||
let errorBuffer = ''
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
const text = data.toString()
|
||||
errorBuffer += text
|
||||
this.emit('log', { id, text })
|
||||
|
||||
if (!started && (text.includes('Output #0') || text.includes('frame='))) {
|
||||
started = true
|
||||
resolve({ pid: proc.pid })
|
||||
}
|
||||
})
|
||||
|
||||
proc.on('error', (err) => {
|
||||
console.error(`[FFmpeg] Process error for ${id}:`, err)
|
||||
if (!started) reject(err)
|
||||
this.emit('error', { id, error: err.message })
|
||||
this.streams.delete(id)
|
||||
})
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
console.log(`[FFmpeg] Stream ${id} exited: code=${code} signal=${signal}`)
|
||||
this.streams.delete(id)
|
||||
this.emit('ended', { id, code, signal })
|
||||
|
||||
// If hwAccel failed, retry with software encoding
|
||||
if (!started && code !== 0 && config.hwAccel !== 'software') {
|
||||
console.log(`[FFmpeg] HW encode failed for ${id}, retrying with software...`)
|
||||
this.startStream({ ...config, hwAccel: 'software' })
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
} else if (!started) {
|
||||
reject(new Error(`FFmpeg exited with code ${code}\n${errorBuffer}`))
|
||||
}
|
||||
})
|
||||
|
||||
// Start timeout
|
||||
setTimeout(() => {
|
||||
if (!started) {
|
||||
started = true
|
||||
resolve({ pid: proc.pid })
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
this.streams.set(id, { process: proc, config })
|
||||
})
|
||||
}
|
||||
|
||||
stopStream(id) {
|
||||
const stream = this.streams.get(id)
|
||||
if (stream) {
|
||||
console.log(`[FFmpeg] Stopping stream ${id}`)
|
||||
if (process.platform === 'win32') {
|
||||
stream.process.kill('SIGTERM')
|
||||
} else {
|
||||
stream.process.kill('SIGINT')
|
||||
}
|
||||
this.streams.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
stopAllStreams() {
|
||||
for (const [id] of this.streams) {
|
||||
this.stopStream(id)
|
||||
}
|
||||
this.stopTolbek()
|
||||
}
|
||||
|
||||
getActiveCount() {
|
||||
return this.streams.size + (this.tolbekProcess ? 1 : 0)
|
||||
}
|
||||
|
||||
getActiveStreams() {
|
||||
const result = []
|
||||
for (const [id, { config }] of this.streams) {
|
||||
result.push({ id, config })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// === TOLBEK (SRT RECEIVER) ===
|
||||
async startTolbek(config) {
|
||||
if (this.tolbekProcess) {
|
||||
this.stopTolbek()
|
||||
}
|
||||
|
||||
const { port, latency, mode, serverAddress } = config
|
||||
const lat = (latency || 200) * 1000
|
||||
const srtMode = mode || 'listener'
|
||||
|
||||
let srtUrl
|
||||
if (srtMode === 'listener') {
|
||||
srtUrl = `srt://0.0.0.0:${port}?mode=listener&latency=${lat}`
|
||||
} else if (srtMode === 'caller') {
|
||||
const addr = (serverAddress || '').replace(/^srt:\/\//, '')
|
||||
srtUrl = `srt://${addr}:${port}?mode=caller&latency=${lat}`
|
||||
} else {
|
||||
// rendezvous
|
||||
const addr = (serverAddress || '0.0.0.0').replace(/^srt:\/\//, '')
|
||||
srtUrl = `srt://${addr}:${port}?mode=rendezvous&latency=${lat}`
|
||||
}
|
||||
|
||||
const args = [
|
||||
'-fflags', 'nobuffer',
|
||||
'-flags', 'low_delay',
|
||||
'-f', 'mpegts',
|
||||
'-i', srtUrl,
|
||||
'-f', 'null', '-'
|
||||
]
|
||||
|
||||
console.log('[FFmpeg] Starting Tolbek receiver:', args.join(' '))
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(this.ffmpegPath, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
let started = false
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
const text = data.toString()
|
||||
this.emit('log', { id: 'tolbek', text })
|
||||
if (!started) {
|
||||
started = true
|
||||
resolve({ pid: proc.pid })
|
||||
}
|
||||
})
|
||||
|
||||
proc.on('error', (err) => {
|
||||
if (!started) reject(err)
|
||||
this.emit('error', { id: 'tolbek', error: err.message })
|
||||
this.tolbekProcess = null
|
||||
})
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
this.tolbekProcess = null
|
||||
this.emit('ended', { id: 'tolbek', code })
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
if (!started) { started = true; resolve({ pid: proc.pid }) }
|
||||
}, 3000)
|
||||
|
||||
this.tolbekProcess = proc
|
||||
})
|
||||
}
|
||||
|
||||
stopTolbek() {
|
||||
if (this.tolbekProcess) {
|
||||
console.log('[FFmpeg] Stopping Tolbek receiver')
|
||||
this.tolbekProcess.kill(process.platform === 'win32' ? 'SIGTERM' : 'SIGINT')
|
||||
this.tolbekProcess = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new FFmpegManager()
|
||||
@@ -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()
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Config
|
||||
getConfig: () => ipcRenderer.invoke('get-config'),
|
||||
saveConfig: (config) => ipcRenderer.invoke('save-config', config),
|
||||
|
||||
// Devices
|
||||
getDevices: () => ipcRenderer.invoke('get-devices'),
|
||||
getWindows: () => ipcRenderer.invoke('get-windows'),
|
||||
|
||||
// Streams
|
||||
startStream: (config) => ipcRenderer.invoke('start-stream', config),
|
||||
stopStream: (id) => ipcRenderer.invoke('stop-stream', id),
|
||||
getActiveStreams: () => ipcRenderer.invoke('get-active-streams'),
|
||||
|
||||
// Tolbek receiver
|
||||
startTolbek: (config) => ipcRenderer.invoke('start-tolbek', config),
|
||||
stopTolbek: () => ipcRenderer.invoke('stop-tolbek'),
|
||||
|
||||
// Window controls
|
||||
minimizeWindow: () => ipcRenderer.invoke('minimize-window'),
|
||||
hideWindow: () => ipcRenderer.invoke('hide-window'),
|
||||
updateTrayCount: (count) => ipcRenderer.send('update-tray-count', count),
|
||||
|
||||
// Event listeners
|
||||
onStreamStatus: (cb) => ipcRenderer.on('stream-status', (_, data) => cb(data)),
|
||||
onStreamLog: (cb) => ipcRenderer.on('stream-log', (_, data) => cb(data)),
|
||||
onStreamError: (cb) => ipcRenderer.on('stream-error', (_, data) => cb(data)),
|
||||
onStreamEnded: (cb) => ipcRenderer.on('stream-ended', (_, data) => cb(data)),
|
||||
onAllStreamsStopped: (cb) => ipcRenderer.on('all-streams-stopped', () => cb()),
|
||||
|
||||
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel)
|
||||
})
|
||||
Reference in New Issue
Block a user