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 }
|
||||
Reference in New Issue
Block a user