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>
This commit is contained in:
admin
2026-04-22 12:10:39 +03:00
parent 6b0c2ca0ae
commit acbd3b6349
11 changed files with 659 additions and 264 deletions
+32
View File
@@ -0,0 +1,32 @@
{
"permissions": {
"allow": [
"Bash(git init:*)",
"Bash(git branch:*)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" https://git.queo.ru/api/v1/user -H \"Authorization: token c4a37376c2c3375987f1a6eb3fd11e1305468e35\")",
"Bash(curl -s -X POST https://git.queo.ru/api/v1/user/repos -H 'Authorization: token c4a37376c2c3375987f1a6eb3fd11e1305468e35' -H 'Content-Type: application/json' -d '{:*)",
"Bash(python -m json.tool)",
"Bash(git config:*)",
"Bash(git remote:*)",
"Bash(git add:*)",
"Bash(git commit -m ':*)",
"Bash(git push:*)",
"Bash(npm --version)",
"Bash(ls /c/project/SRT_stream/node_modules/.bin/electron*)",
"Bash(node scripts/build-icon.js)",
"Bash(npm run:*)",
"Bash(npm install:*)",
"Bash(set CSC_IDENTITY_AUTO_DISCOVERY=false)",
"Bash(npx cross-env:*)",
"Bash(where 7z)",
"Bash(where makensis:*)",
"Read(//c/Program Files/7-Zip/**)",
"Read(//c/Program Files/NSIS/**)",
"Bash(node scripts/fix-wincodeSign.js)",
"Bash(ls /c/project/SRT_stream/dist-electron/*.exe)",
"Bash(curl -s -X POST https://git.queo.ru/api/v1/repos/admin/srt-streamer/releases -H 'Authorization: token c4a37376c2c3375987f1a6eb3fd11e1305468e35' -H 'Content-Type: application/json' -d '{:*)",
"Bash(curl -s -X POST https://git.queo.ru/api/v1/repos/admin/srt-streamer/releases -H 'Authorization: token c4a37376c2c3375987f1a6eb3fd11e1305468e35' -H 'Content-Type: application/json' -d '{\"tag_name\":\"v1.0.0\",\"name\":\"SRT Streamer v1.0.0\",\"body\":\"Portable Windows EXE\",\"draft\":false,\"prerelease\":false}')",
"Bash(npx vite *)"
]
}
}
+78 -54
View File
@@ -26,8 +26,6 @@ function runCommand(cmd, args, timeoutMs = 10000) {
proc.stdout.on('data', d => outBufs.push(d))
proc.stderr.on('data', d => errBufs.push(d))
const finish = () => {
// FFmpeg outputs UTF-8; on Russian Windows system tools may use CP1251,
// but FFmpeg is always UTF-8 — safe to decode as utf8 here.
const stdout = Buffer.concat(outBufs).toString('utf8')
const stderr = Buffer.concat(errBufs).toString('utf8')
resolve({ stdout, stderr })
@@ -38,12 +36,9 @@ function runCommand(cmd, args, timeoutMs = 10000) {
})
}
// ─── Windows — enumerate application windows via PowerShell ───────────────────
// KEY: force UTF-8 output BEFORE any text is written, receive as Buffer,
// then decode as UTF-8 — this fixes Cyrillic/Unicode window titles on Russian Windows.
// ─── Windows — enumerate open windows via PowerShell (UTF-8 safe) ─────────────
function getWindowsWindowsList() {
return new Promise((resolve) => {
// Force UTF-8 console output so Node receives proper Unicode
const ps1 = [
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;',
'$OutputEncoding = [System.Text.Encoding]::UTF8;',
@@ -56,14 +51,12 @@ function getWindowsWindowsList() {
execFile(
'powershell.exe',
['-NonInteractive', '-NoProfile', '-WindowStyle', 'Hidden', '-Command', ps1],
// encoding: 'buffer' — receive raw bytes, we decode manually as UTF-8
{ timeout: 8000, windowsHide: true, encoding: 'buffer' },
(err, stdoutBuf) => {
if (err || !stdoutBuf || !stdoutBuf.length) { resolve([]); return }
// Decode the buffer as UTF-8 (PowerShell wrote UTF-8 BOM-less)
// Strip UTF-8 BOM if present (EF BB BF)
let buf = stdoutBuf
// Strip UTF-8 BOM if present
if (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) buf = buf.slice(3)
const stdout = buf.toString('utf8')
@@ -83,7 +76,57 @@ function getWindowsWindowsList() {
})
}
// ─── Windows — enumerate dShow devices + monitors ─────────────────────────────
// ─── Windows — enumerate monitors with exact pixel bounds ─────────────────────
// Uses System.Windows.Forms.Screen (always available on Windows)
function getWindowsMonitors() {
return new Promise((resolve) => {
const ps1 = [
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;',
'$OutputEncoding = [System.Text.Encoding]::UTF8;',
'Add-Type -AssemblyName System.Windows.Forms;',
'[System.Windows.Forms.Screen]::AllScreens',
'| Sort-Object { $_.Primary } -Descending', // primary first
'| ForEach-Object {',
' $b = $_.Bounds;',
' Write-Output ("{0}|{1}|{2}|{3}|{4}|{5}" -f $_.DeviceName, $b.X, $b.Y, $b.Width, $b.Height, $_.Primary)',
'}'
].join(' ')
execFile(
'powershell.exe',
['-NonInteractive', '-NoProfile', '-WindowStyle', 'Hidden', '-Command', ps1],
{ timeout: 8000, windowsHide: true, encoding: 'buffer' },
(err, stdoutBuf) => {
if (err || !stdoutBuf || !stdoutBuf.length) { resolve([]); return }
let buf = stdoutBuf
if (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) buf = buf.slice(3)
const stdout = buf.toString('utf8')
const monitors = []
for (const line of stdout.trim().split('\n')) {
const parts = line.trim().replace(/\r$/, '').split('|')
if (parts.length < 5) continue
const [deviceName, x, y, w, h, primary] = parts
monitors.push({
label: (deviceName || '').trim().replace(/\\\\.\\/, '').trim(),
bounds: {
x: parseInt(x) || 0,
y: parseInt(y) || 0,
width: parseInt(w) || 1920,
height: parseInt(h) || 1080,
},
primary: primary?.trim().toLowerCase() === 'true'
})
}
resolve(monitors)
}
)
})
}
// ─── Windows — dShow devices + monitors + windows ─────────────────────────────
async function getWindowsDevices() {
const ffmpeg = getFfmpegPath()
const { stderr } = await runCommand(ffmpeg, ['-list_devices', 'true', '-f', 'dshow', '-i', 'dummy'])
@@ -98,31 +141,39 @@ async function getWindowsDevices() {
if (audioMatch) { audio.push({ name: audioMatch[1], deviceName: audioMatch[1], type: 'device' }) }
}
// System audio loopback (works with VB-Audio, Stereo Mix, or virtual-audio-capturer)
// System audio options
audio.push({
name: 'Системный звук (Loopback / Stereo Mix)',
name: 'Системный звук (WASAPI Loopback)',
deviceName: '__wasapi_loopback__',
type: 'system',
description: 'Нативный захват Windows — без стороннего ПО'
})
audio.push({
name: 'Системный звук (VB-Audio / Stereo Mix)',
deviceName: 'virtual-audio-capturer',
type: 'system',
description: 'Захват всего выходного звука'
description: 'Требует VB-Audio Cable или Stereo Mix'
})
// Screen sources: full desktop
// Screen sources
const screenDevices = [
{ name: 'Рабочий стол (весь экран)', type: 'desktop', deviceName: 'desktop' }
]
// Per-monitor entries
// Per-monitor entries with real pixel bounds
const monitors = await getWindowsMonitors()
monitors.forEach((m, i) => {
const label = m.primary ? `${m.label} (основной)` : m.label
screenDevices.push({
name: `Монитор ${i + 1}${m.label ? ': ' + m.label : ''}`,
name: `Монитор ${i + 1}${label ? ': ' + label : ''}${m.bounds.width}×${m.bounds.height}`,
type: 'desktop',
deviceName: 'desktop',
monitorIndex: i
deviceName: `desktop_monitor_${i}`, // unique key per monitor
monitorIndex: i,
monitorBounds: m.bounds // { x, y, width, height }
})
})
// Enumerate open windows — included directly in video sources
// Open application windows
const wins = await getWindowsWindowsList()
const windowDevices = wins.map(w => ({
name: w.title,
@@ -138,22 +189,6 @@ async function getWindowsDevices() {
}
}
async function getWindowsMonitors() {
return new Promise((resolve) => {
const t = setTimeout(() => resolve([]), 5000)
exec('wmic desktopmonitor get Name /format:list', (err, stdout) => {
clearTimeout(t)
if (err || !stdout) { resolve([]); return }
const monitors = []
for (const block of stdout.split(/\r?\n\r?\n/)) {
const m = block.match(/Name=(.+)/)
if (m && m[1].trim()) monitors.push({ label: m[1].trim() })
}
resolve(monitors)
})
})
}
// ─── macOS ────────────────────────────────────────────────────────────────────
async function getMacDevices() {
const ffmpeg = getFfmpegPath()
@@ -176,13 +211,10 @@ async function getMacDevices() {
}
}
// macOS window list via osascript
const wins = await getMacWindowsList()
const windowDevices = wins.map(w => ({
name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title
}))
video.push(...wins.map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title })))
return { video: [...video, ...windowDevices], audio }
return { video, audio }
}
function getMacWindowsList() {
@@ -190,9 +222,8 @@ function getMacWindowsList() {
const script = 'tell application "System Events" to get the name of every window of every process whose visible is true'
execFile('osascript', ['-e', script], { timeout: 5000 }, (err, stdout) => {
if (err || !stdout) { resolve([]); return }
const wins = stdout.trim().split(',').map(s => s.trim()).filter(Boolean)
.map(title => ({ title, type: 'window' }))
resolve(wins)
resolve(stdout.trim().split(',').map(s => s.trim()).filter(Boolean)
.map(title => ({ title, type: 'window' })))
})
})
}
@@ -201,7 +232,6 @@ function getMacWindowsList() {
async function getLinuxDevices() {
const video = [], audio = []
// V4L2 cameras
try {
const { stdout: devList } = await runCommand('ls', ['/dev/video0', '/dev/video1', '/dev/video2', '/dev/video3', '/dev/video4'])
for (const dev of devList.trim().split('\n').filter(Boolean)) {
@@ -212,12 +242,9 @@ async function getLinuxDevices() {
const display = process.env.DISPLAY || ':0'
video.unshift({ name: `Рабочий стол (${display})`, type: 'desktop', devicePath: display })
// Linux windows
const wins = await getLinuxWindowsList()
const windowDevices = wins.map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title }))
video.push(...windowDevices)
video.push(...wins.map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title })))
// PulseAudio / ALSA
try {
const { stdout: paList } = await runCommand('pactl', ['list', 'short', 'sources'])
for (const line of paList.split('\n').filter(Boolean)) {
@@ -243,14 +270,12 @@ function getLinuxWindowsList() {
return new Promise((resolve) => {
execFile('wmctrl', ['-l'], { timeout: 3000 }, (err, stdout) => {
if (err || !stdout) { resolve([]); return }
const wins = stdout.trim().split('\n')
resolve(stdout.trim().split('\n')
.map(line => {
const parts = line.split(/\s+/)
const title = parts.slice(3).join(' ').trim()
return { title, type: 'window' }
return { title: parts.slice(3).join(' ').trim(), type: 'window' }
})
.filter(w => w.title)
resolve(wins)
.filter(w => w.title))
})
})
}
@@ -273,7 +298,6 @@ async function getDevices() {
return result
}
// Still exposed separately for the refresh button
async function getWindows() {
if (process.platform === 'win32') return getWindowsWindowsList()
if (process.platform === 'darwin') return getMacWindowsList()
+142 -109
View File
@@ -1,74 +1,79 @@
'use strict'
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._manualStops = new Set() // IDs stopped by user (no reconnect)
this._reconnectTimers = new Map() // id -> setTimeout handle
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
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
// ─── Build FFmpeg args ────────────────────────────────────────────────────────
_buildStreamArgs(config) {
const args = []
const platform = process.platform
// === VIDEO INPUT ===
const { videoSource, audioSource } = config
// ── VIDEO INPUT ──────────────────────────────────────────────────────────────
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('-draw_mouse', videoSource.hideCursor ? '0' : '1')
args.push('-framerate', String(config.framerate || 30))
// Per-monitor capture: use stored bounds if available
if (videoSource.monitorBounds) {
const { x, y, width, height } = videoSource.monitorBounds
args.push('-offset_x', String(x))
args.push('-offset_y', String(y))
args.push('-video_size', `${width}x${height}`)
}
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`)
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}`)
// Per-monitor: use bounds if available
if (videoSource.monitorBounds) {
const { x, y, width, height } = videoSource.monitorBounds
args.push('-s', `${width}x${height}`)
args.push('-i', `${display}+${x},${y}`)
} else {
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}`)
args.push('-i', `title=${videoSource.windowTitle || videoSource.deviceName}`)
} 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`)
@@ -78,7 +83,6 @@ class FFmpegManager extends EventEmitter {
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))
@@ -95,18 +99,17 @@ class FFmpegManager extends EventEmitter {
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
} else {
// No video black frame
args.push('-f', 'lavfi', '-i', 'color=black:640x480:rate=25')
}
// === AUDIO INPUT ===
// ── 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}`)
if (audioSource.deviceName === '__wasapi_loopback__') {
// Native WASAPI loopback — captures all system audio, no extra software needed
args.push('-f', 'wasapi', '-loopback', '1', '-i', '')
} else {
args.push('-f', 'dshow')
args.push('-i', `audio=${audioSource.deviceName}`)
@@ -116,7 +119,6 @@ class FFmpegManager extends EventEmitter {
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 {
@@ -126,11 +128,10 @@ class FFmpegManager extends EventEmitter {
}
}
// === VIDEO ENCODING ===
// ── 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') {
@@ -140,40 +141,49 @@ class FFmpegManager extends EventEmitter {
} 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)
args.push('-b:v', videoBitrate, '-maxrate', videoBitrate, '-bufsize', videoBitrate)
if (config.resolution) {
args.push('-vf', `scale=${config.resolution.replace('x', ':')}`)
}
args.push('-g', String((config.framerate || 30) * 2))
args.push('-g', String((config.framerate || 30) * 2)) // keyframe interval
// === AUDIO ENCODING ===
// ── AUDIO ENCODING ────────────────────────────────────────────────────────────
if (audioSource && audioSource.type !== 'none') {
args.push('-c:a', 'aac')
args.push('-b:a', config.audioBitrate || '128k')
args.push('-ar', '44100')
args.push('-c:a', 'aac', '-b:a', config.audioBitrate || '128k', '-ar', '44100')
} else {
args.push('-an')
}
// === SRT OUTPUT ===
// ── OUTPUT (SRT / tee with recording) ─────────────────────────────────────────
const { serverAddress, port, srtMode, latency } = config
const address = serverAddress.replace(/^(srt:\/\/|rtmp:\/\/|http:\/\/|https:\/\/)/, '')
const lat = latency || 200
const mode = srtMode || 'caller'
const srtUrl = `srt://${address}:${port}?mode=${mode}&latency=${lat * 1000}&pkt_size=1316`
let srtUrl = `srt://${address}:${port}?mode=${mode}&latency=${lat * 1000}&pkt_size=1316`
if (config.record && config.recordFolder) {
// Tee muxer: stream to SRT AND write segmented file simultaneously
const segSecs = Math.max(1, config.recordSegmentMinutes || 60) * 60
const safeName = (config.name || config.id).replace(/[^a-zA-Z0-9_-]/g, '_')
args.push('-f', 'mpegts')
args.push(srtUrl)
// Build file path — convert Windows backslashes, escape drive colon for tee muxer
let folder = (config.recordFolder + '').replace(/\\/g, '/')
if (process.platform === 'win32') {
folder = folder.replace(/^([A-Za-z]):/, '$1\\:')
}
const filePath = `${folder}/${safeName}_%Y%m%d_%H%M%S.ts`
const fileOpts = `f=segment:segment_time=${segSecs}:strftime=1:reset_timestamps=1`
const teeOut = `[f=mpegts]${srtUrl}|[${fileOpts}]${filePath}`
args.push('-f', 'tee', teeOut)
} else {
args.push('-f', 'mpegts', srtUrl)
}
return args
}
@@ -181,34 +191,51 @@ class FFmpegManager extends EventEmitter {
_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
return 'h264_nvenc' // will fallback to software 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'
return {
nvenc: 'h264_nvenc',
qsv: 'h264_qsv',
amf: 'h264_amf',
videotoolbox: 'h264_videotoolbox',
vaapi: 'h264_vaapi',
software: 'libx264',
}[hwAccel] || 'libx264'
}
// ─── Public: start stream ────────────────────────────────────────────────────
async startStream(config) {
const { id } = config
if (this.streams.has(id)) {
this.stopStream(id)
// Cancel any pending reconnect timer
if (this._reconnectTimers.has(id)) {
clearTimeout(this._reconnectTimers.get(id))
this._reconnectTimers.delete(id)
}
const args = this._buildStreamArgs(config)
console.log(`[FFmpeg] Starting stream ${id}:`, this.ffmpegPath, args.join(' '))
// Kill existing process for this id if any
if (this.streams.has(id)) {
const existing = this.streams.get(id)
try { existing.process.kill() } catch {}
this.streams.delete(id)
}
// Mark as NOT a manual stop (so reconnect will fire on unexpected exits)
this._manualStops.delete(id)
return this._spawn(config, 0, true)
}
// ─── Internal: spawn ffmpeg process ─────────────────────────────────────────
_spawn(config, attempt, initial) {
const { id } = config
return new Promise((resolve, reject) => {
const proc = spawn(this.ffmpegPath, args, {
stdio: ['pipe', 'pipe', 'pipe']
})
const args = this._buildStreamArgs(config)
console.log(`[FFmpeg] Starting stream ${id} (attempt ${attempt}):`, this.ffmpegPath, args.join(' '))
const proc = spawn(this.ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] })
let started = false
let errorBuffer = ''
@@ -220,38 +247,57 @@ class FFmpegManager extends EventEmitter {
if (!started && (text.includes('Output #0') || text.includes('frame='))) {
started = true
resolve({ pid: proc.pid })
if (initial) resolve({ pid: proc.pid })
}
})
proc.on('error', (err) => {
console.error(`[FFmpeg] Process error for ${id}:`, err)
if (!started) reject(err)
console.error(`[FFmpeg] Spawn error ${id}:`, err)
if (initial && !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}`)
console.log(`[FFmpeg] Stream ${id} exited: code=${code} signal=${signal} attempt=${attempt}`)
const wasManual = this._manualStops.has(id)
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) {
// ── Initial start failed — try hwAccel fallback first ─────────────────
if (initial && !started && code !== 0 && config.hwAccel !== 'software') {
console.log(`[FFmpeg] HW encode failed for ${id}, retrying with software`)
this._spawn({ ...config, hwAccel: 'software' }, 0, true)
.then(resolve).catch(reject)
return
}
if (initial && !started) {
reject(new Error(`FFmpeg exited with code ${code}\n${errorBuffer}`))
return
}
// ── Auto-reconnect on unexpected exit ─────────────────────────────────
if (!wasManual) {
const delayMs = Math.min(2000 * Math.pow(1.5, attempt), 30000)
const nextAttempt = attempt + 1
console.log(`[FFmpeg] Scheduling reconnect for ${id} in ${delayMs}ms (attempt ${nextAttempt})`)
this.emit('reconnecting', { id, attempt: nextAttempt, delayMs })
const timer = setTimeout(() => {
this._reconnectTimers.delete(id)
if (!this._manualStops.has(id)) {
this._spawn(config, nextAttempt, false)
}
}, delayMs)
this._reconnectTimers.set(id, timer)
}
})
// Start timeout
// Startup timeout — resolve even if FFmpeg hasn't printed "Output #0" yet
setTimeout(() => {
if (!started) {
started = true
resolve({ pid: proc.pid })
if (initial) resolve({ pid: proc.pid })
}
}, 5000)
@@ -259,23 +305,27 @@ class FFmpegManager extends EventEmitter {
})
}
// ─── Public: stop stream ─────────────────────────────────────────────────────
stopStream(id) {
// Cancel any pending reconnect
if (this._reconnectTimers.has(id)) {
clearTimeout(this._reconnectTimers.get(id))
this._reconnectTimers.delete(id)
}
// Mark as manual stop so reconnect won't fire
this._manualStops.add(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')
}
stream.process.kill(process.platform === 'win32' ? 'SIGTERM' : 'SIGINT')
this.streams.delete(id)
}
}
stopAllStreams() {
for (const [id] of this.streams) {
this.stopStream(id)
}
for (const [id] of this.streams) this.stopStream(id)
this.stopTolbek()
}
@@ -285,17 +335,13 @@ class FFmpegManager extends EventEmitter {
getActiveStreams() {
const result = []
for (const [id, { config }] of this.streams) {
result.push({ id, config })
}
for (const [id, { config }] of this.streams) result.push({ id, config })
return result
}
// === TOLBEK (SRT RECEIVER) ===
// ─── Tolbek SRT Receiver ─────────────────────────────────────────────────────
async startTolbek(config) {
if (this.tolbekProcess) {
this.stopTolbek()
}
if (this.tolbekProcess) this.stopTolbek()
const { port, latency, mode, serverAddress } = config
const lat = (latency || 200) * 1000
@@ -308,51 +354,38 @@ class FFmpegManager extends EventEmitter {
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,
'-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']
})
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 })
}
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)
setTimeout(() => { if (!started) { started = true; resolve({ pid: proc.pid }) } }, 3000)
this.tolbekProcess = proc
})
+22 -7
View File
@@ -1,4 +1,4 @@
const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage, dialog } = require('electron')
const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage, dialog, shell } = require('electron')
const path = require('path')
const fs = require('fs')
const ffmpegManager = require('./ffmpeg')
@@ -124,7 +124,7 @@ function loadConfig() {
} catch (e) {
console.error('Failed to load config:', e)
}
return { streams: [], tolbek: { enabled: false, port: 5000, latency: 200 } }
return { streams: [], tolbek: { enabled: false, port: 5000, latency: 200 }, recording: { folder: '', segmentMinutes: 60 } }
}
function saveConfig(config) {
@@ -323,6 +323,19 @@ 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()
})
@@ -335,21 +348,23 @@ ipcMain.on('update-tray-count', (_, count) => {
updateTrayMenu(count)
})
// Forward FFmpeg log events to renderer
// 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)
const active = ffmpegManager.getActiveCount()
updateTrayMenu(active)
updateTrayMenu(ffmpegManager.getActiveCount())
})
ffmpegManager.on('ended', (data) => {
mainWindow && mainWindow.webContents.send('stream-ended', data)
const active = ffmpegManager.getActiveCount()
updateTrayMenu(active)
updateTrayMenu(ffmpegManager.getActiveCount())
})
ffmpegManager.on('reconnecting', (data) => {
mainWindow && mainWindow.webContents.send('stream-reconnecting', data)
})
app.whenReady().then(() => {
+9 -4
View File
@@ -18,16 +18,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
startTolbek: (config) => ipcRenderer.invoke('start-tolbek', config),
stopTolbek: () => ipcRenderer.invoke('stop-tolbek'),
// File system helpers
pickFolder: () => ipcRenderer.invoke('pick-folder'),
openFolder: (p) => ipcRenderer.invoke('open-folder', p),
// 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)),
onStreamStatus: (cb) => ipcRenderer.on('stream-status', (_, d) => cb(d)),
onStreamLog: (cb) => ipcRenderer.on('stream-log', (_, d) => cb(d)),
onStreamError: (cb) => ipcRenderer.on('stream-error', (_, d) => cb(d)),
onStreamEnded: (cb) => ipcRenderer.on('stream-ended', (_, d) => cb(d)),
onStreamReconnecting: (cb) => ipcRenderer.on('stream-reconnecting', (_, d) => cb(d)),
onAllStreamsStopped: (cb) => ipcRenderer.on('all-streams-stopped', () => cb()),
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "srt-streamer",
"version": "1.0.0",
"version": "1.2.0",
"description": "Cross-platform SRT multi-stream sender with system tray",
"main": "electron/main.js",
"scripts": {
+74 -59
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react'
import StreamCard from './components/StreamCard.jsx'
import TolbekSettings from './components/TolbekSettings.jsx'
import LogPanel from './components/LogPanel.jsx'
import RecordingBar from './components/RecordingBar.jsx'
const ipc = window.electronAPI || null
@@ -19,6 +20,7 @@ const DEFAULT_STREAM = () => ({
videoBitrate: '4000k',
audioBitrate: '128k',
hwAccel: 'auto',
record: false,
status: 'idle'
})
@@ -30,6 +32,8 @@ export default function App() {
const [logs, setLogs] = useState([])
const [activeTab, setActiveTab] = useState('streams')
const [devicesLoaded, setDevicesLoaded] = useState(false)
// Global recording settings
const [recording, setRecording] = useState({ folder: '', segmentMinutes: 60 })
// Load config and devices on mount
useEffect(() => {
@@ -37,16 +41,13 @@ export default function App() {
ipc.getConfig().then(config => {
if (config.streams && config.streams.length > 0) {
setStreams(config.streams.map(s => ({ ...s, status: 'idle' })))
setStreams(config.streams.map(s => ({ ...DEFAULT_STREAM(), ...s, status: 'idle' })))
}
if (config.tolbek) setTolbek(config.tolbek)
if (config.recording) setRecording(config.recording)
})
ipc.getDevices().then(devs => {
setDevices(devs)
setDevicesLoaded(true)
})
ipc.getDevices().then(devs => { setDevices(devs); setDevicesLoaded(true) })
ipc.getWindows().then(wins => setWindows(wins))
}, [])
@@ -54,27 +55,37 @@ export default function App() {
useEffect(() => {
if (!ipc) return
const handleStatus = ({ id, status }) => {
const handleStatus = ({ id, status }) =>
setStreams(prev => prev.map(s => s.id === id ? { ...s, status } : s))
}
const handleLog = ({ id, text }) => {
const handleLog = ({ id, text }) =>
setLogs(prev => [...prev.slice(-499), { id, text: text.trim(), time: Date.now() }])
}
const handleError = ({ id, error }) => {
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'error' } : s))
setLogs(prev => [...prev.slice(-499), { id, text: `ERROR: ${error}`, time: Date.now(), isError: true }])
}
const handleEnded = ({ id }) => {
const handleEnded = ({ id }) =>
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'idle' } : s))
const handleReconnecting = ({ id, attempt, delayMs }) => {
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'connecting' } : s))
setLogs(prev => [...prev.slice(-499), {
id,
text: `Переподключение… попытка ${attempt}, через ${(delayMs / 1000).toFixed(1)}с`,
time: Date.now()
}])
}
const handleAllStopped = () => {
const handleAllStopped = () =>
setStreams(prev => prev.map(s => ({ ...s, status: 'idle' })))
}
ipc.onStreamStatus(handleStatus)
ipc.onStreamLog(handleLog)
ipc.onStreamError(handleError)
ipc.onStreamEnded(handleEnded)
ipc.onStreamReconnecting(handleReconnecting)
ipc.onAllStreamsStopped(handleAllStopped)
return () => {
@@ -82,27 +93,32 @@ export default function App() {
ipc.removeAllListeners('stream-log')
ipc.removeAllListeners('stream-error')
ipc.removeAllListeners('stream-ended')
ipc.removeAllListeners('stream-reconnecting')
ipc.removeAllListeners('all-streams-stopped')
}
}, [])
// Persist config when streams/tolbek change
// Persist config when streams / tolbek / recording change
useEffect(() => {
if (!ipc) return
const saveData = { streams: streams.map(s => { const {status, ...rest} = s; return rest }), tolbek }
const saveData = {
streams: streams.map(s => { const { status, ...rest } = s; return rest }),
tolbek,
recording
}
ipc.saveConfig(saveData)
ipc.updateTrayCount(streams.filter(s => s.status === 'running').length)
}, [streams, tolbek])
}, [streams, tolbek, recording])
const addStream = useCallback(() => {
const newStream = DEFAULT_STREAM()
newStream.name = `Stream ${streams.length + 1}`
setStreams(prev => [...prev, newStream])
const s = DEFAULT_STREAM()
s.name = `Stream ${streams.length + 1}`
setStreams(prev => [...prev, s])
}, [streams.length])
const updateStream = useCallback((id, changes) => {
const updateStream = useCallback((id, changes) =>
setStreams(prev => prev.map(s => s.id === id ? { ...s, ...changes } : s))
}, [])
, [])
const removeStream = useCallback((id) => {
if (ipc) ipc.stopStream(id)
@@ -114,12 +130,17 @@ export default function App() {
const stream = streams.find(s => s.id === id)
if (!stream) return
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'connecting' } : s))
const result = await ipc.startStream(stream)
// Pass global recording settings into the stream config
const result = await ipc.startStream({
...stream,
recordFolder: recording.folder || null,
recordSegmentMinutes: recording.segmentMinutes || 60
})
if (!result.success) {
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'error' } : s))
setLogs(prev => [...prev, { id, text: `Failed to start: ${result.error}`, time: Date.now(), isError: true }])
}
}, [streams])
}, [streams, recording])
const stopStream = useCallback(async (id) => {
if (!ipc) return
@@ -130,17 +151,21 @@ export default function App() {
const refreshDevices = useCallback(() => {
if (!ipc) return
setDevicesLoaded(false)
ipc.getDevices().then(devs => {
setDevices(devs)
setDevicesLoaded(true)
})
ipc.getDevices().then(devs => { setDevices(devs); setDevicesLoaded(true) })
ipc.getWindows().then(wins => setWindows(wins))
}, [])
// Toggle record flag for all streams
const setAllRecord = useCallback((val) => {
setStreams(prev => prev.map(s => ({ ...s, record: val })))
}, [])
const handleMinimize = () => ipc && ipc.minimizeWindow()
const handleHide = () => ipc && ipc.hideWindow()
const activeCount = streams.filter(s => s.status === 'running' || s.status === 'connecting').length
const recordingCount = streams.filter(s => s.record).length
const hasErrors = logs.some(l => l.isError)
return (
<div className="app">
@@ -149,9 +174,7 @@ export default function App() {
<div className="titlebar-left">
<span className="app-logo"></span>
<span className="app-title">SRT Streamer</span>
{activeCount > 0 && (
<span className="active-badge">{activeCount} active</span>
)}
{activeCount > 0 && <span className="active-badge">{activeCount} active</span>}
</div>
<div className="titlebar-controls" style={{ WebkitAppRegion: 'no-drag' }}>
<button className="tb-btn" onClick={handleMinimize} title="Minimize">&#8722;</button>
@@ -161,26 +184,14 @@ export default function App() {
{/* Tab Bar */}
<div className="tabbar">
<button
className={`tab ${activeTab === 'streams' ? 'active' : ''}`}
onClick={() => setActiveTab('streams')}
>
Streams
{activeCount > 0 && <span className="tab-badge">{activeCount}</span>}
<button className={`tab ${activeTab === 'streams' ? 'active' : ''}`} onClick={() => setActiveTab('streams')}>
Streams {activeCount > 0 && <span className="tab-badge">{activeCount}</span>}
</button>
<button
className={`tab ${activeTab === 'tolbek' ? 'active' : ''}`}
onClick={() => setActiveTab('tolbek')}
>
Tolbek (Receiver)
{tolbek.running && <span className="tab-badge tab-badge--green"></span>}
<button className={`tab ${activeTab === 'tolbek' ? 'active' : ''}`} onClick={() => setActiveTab('tolbek')}>
Tolbek (Receiver) {tolbek.running && <span className="tab-badge tab-badge--green"></span>}
</button>
<button
className={`tab ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveTab('logs')}
>
Logs
{logs.some(l => l.isError) && <span className="tab-badge tab-badge--red">!</span>}
<button className={`tab ${activeTab === 'logs' ? 'active' : ''}`} onClick={() => setActiveTab('logs')}>
Logs {hasErrors && <span className="tab-badge tab-badge--red">!</span>}
</button>
<div className="tabbar-spacer" />
<button className="tab tab--icon" onClick={refreshDevices} title="Refresh device list">
@@ -188,6 +199,18 @@ export default function App() {
</button>
</div>
{/* Global recording bar — shown only on Streams tab */}
{activeTab === 'streams' && (
<RecordingBar
recording={recording}
onChange={setRecording}
recordingCount={recordingCount}
totalCount={streams.length}
onSelectAll={setAllRecord}
ipc={ipc}
/>
)}
{/* Content */}
<div className="content">
{activeTab === 'streams' && (
@@ -200,6 +223,7 @@ export default function App() {
devices={devices}
windows={windows}
devicesLoaded={devicesLoaded}
recordFolder={recording.folder}
onChange={(changes) => updateStream(stream.id, changes)}
onStart={() => startStream(stream.id)}
onStop={() => stopStream(stream.id)}
@@ -207,7 +231,6 @@ export default function App() {
onRefreshDevices={refreshDevices}
/>
))}
<button className="add-stream-btn" onClick={addStream}>
<span className="add-icon">+</span>
<span>Add Stream</span>
@@ -217,19 +240,11 @@ export default function App() {
)}
{activeTab === 'tolbek' && (
<TolbekSettings
config={tolbek}
onChange={setTolbek}
ipc={ipc}
/>
<TolbekSettings config={tolbek} onChange={setTolbek} ipc={ipc} />
)}
{activeTab === 'logs' && (
<LogPanel
logs={logs}
streams={streams}
onClear={() => setLogs([])}
/>
<LogPanel logs={logs} streams={streams} onClear={() => setLogs([])} />
)}
</div>
</div>
+87
View File
@@ -0,0 +1,87 @@
import React from 'react'
export default function RecordingBar({ recording, onChange, recordingCount, totalCount, onSelectAll, ipc }) {
const allSelected = totalCount > 0 && recordingCount === totalCount
const someSelected = recordingCount > 0 && recordingCount < totalCount
async function handlePickFolder() {
if (!ipc) return
const folder = await ipc.pickFolder()
if (folder) onChange({ ...recording, folder })
}
function handleOpenFolder() {
if (ipc && recording.folder) ipc.openFolder(recording.folder)
}
return (
<div className="recording-bar">
{/* Folder picker */}
<div className="rec-folder-section">
<span className="rec-label">🎬 Запись:</span>
<button
className="btn btn--sm btn--outline"
onClick={handlePickFolder}
title="Выбрать папку для записи"
>
📁 Папка
</button>
{recording.folder ? (
<span
className="rec-folder-path"
title={recording.folder}
onClick={handleOpenFolder}
>
{recording.folder}
</span>
) : (
<span className="rec-folder-empty">не выбрана</span>
)}
</div>
{/* Segment duration */}
<div className="rec-segment-section">
<label className="rec-label">Сегмент:</label>
<input
className="rec-segment-input"
type="number"
min="1"
max="1440"
value={recording.segmentMinutes}
onChange={e => onChange({ ...recording, segmentMinutes: Math.max(1, parseInt(e.target.value) || 60) })}
title="Длительность файла в минутах"
/>
<span className="rec-label-unit">мин</span>
</div>
{/* Select all / none */}
<div className="rec-select-section">
<label
className={`rec-selectall-label ${allSelected ? 'rec-selectall--on' : someSelected ? 'rec-selectall--partial' : ''}`}
title={allSelected ? 'Снять выбор со всех' : 'Записывать все потоки'}
>
<input
type="checkbox"
checked={allSelected}
ref={el => { if (el) el.indeterminate = someSelected }}
onChange={e => onSelectAll(e.target.checked)}
style={{ display: 'none' }}
/>
<span className="rec-selectall-box">
{allSelected ? '☑' : someSelected ? '⊟' : '☐'}
</span>
Все потоки
</label>
{recordingCount > 0 && (
<span className="rec-active-badge">
{recordingCount} / {totalCount}
</span>
)}
</div>
{!recording.folder && recordingCount > 0 && (
<span className="rec-warn"> Выберите папку</span>
)}
</div>
)
}
+6 -3
View File
@@ -5,17 +5,19 @@ const STATUS_LABELS = {
idle: 'Idle',
connecting: 'Connecting…',
running: 'Live',
error: 'Error'
error: 'Error',
reconnecting: 'Reconnecting…'
}
const STATUS_CLASS = {
idle: 'status--idle',
connecting: 'status--connecting',
running: 'status--running',
error: 'status--error'
error: 'status--error',
reconnecting: 'status--connecting'
}
export default function StreamCard({ stream, devices, windows, devicesLoaded, onChange, onStart, onStop, onRemove, onRefreshDevices }) {
export default function StreamCard({ stream, devices, windows, devicesLoaded, recordFolder, onChange, onStart, onStop, onRemove, onRefreshDevices }) {
const [expanded, setExpanded] = useState(true)
const [editingName, setEditingName] = useState(false)
const [nameValue, setNameValue] = useState(stream.name)
@@ -121,6 +123,7 @@ export default function StreamCard({ stream, devices, windows, devicesLoaded, on
disabled={isActive}
onChange={onChange}
onRefreshDevices={onRefreshDevices}
recordFolder={recordFolder}
/>
)}
</div>
+23 -2
View File
@@ -95,7 +95,7 @@ function detectProfile(res, fps, vbr) {
}
// ─── main component ───────────────────────────────────────────────────────────
export default function StreamSettings({ stream, devices, windows, devicesLoaded, disabled, onChange, onRefreshDevices }) {
export default function StreamSettings({ stream, devices, windows, devicesLoaded, disabled, onChange, onRefreshDevices, recordFolder }) {
const [activeSection, setActiveSection] = useState('source')
const updateVideo = changes => onChange({ videoSource: { ...stream.videoSource, ...changes } })
@@ -138,8 +138,9 @@ export default function StreamSettings({ stream, devices, windows, devicesLoaded
type: dev.type,
deviceIndex: dev.deviceIndex,
devicePath: dev.devicePath,
windowTitle: dev.windowTitle,
windowTitle: dev.windowTitle || dev.name,
screenIndex: dev.monitorIndex,
monitorBounds: dev.monitorBounds || null,
})
}
}
@@ -228,6 +229,26 @@ export default function StreamSettings({ stream, devices, windows, devicesLoaded
)}
</div>
{/* Recording checkbox */}
<div className="settings-group">
<div className="group-title">Запись</div>
<label className={`checkbox-label field rec-checkbox-label ${stream.record ? 'rec-checkbox-label--on' : ''}`}>
<input
type="checkbox"
checked={stream.record || false}
onChange={e => onChange({ record: e.target.checked })}
disabled={disabled}
/>
<span>Записывать этот поток</span>
{stream.record && !recordFolder && (
<span className="rec-warn-inline"> выберите папку в панели записи</span>
)}
{stream.record && recordFolder && (
<span className="rec-folder-hint"> {recordFolder}</span>
)}
</label>
</div>
{/* Audio */}
<div className="settings-group">
<div className="group-title">Аудиоисточник</div>
+160
View File
@@ -761,3 +761,163 @@ html, body, #root {
/* ========== SELECT OPTION STYLING ========== */
option { background: #1a1a2e; color: var(--text-primary); }
option:disabled { color: var(--text-muted); }
/* ========== RECORDING BAR ========== */
.recording-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 14px;
background: rgba(239, 68, 68, 0.06);
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
flex-shrink: 0;
flex-wrap: wrap;
min-height: 38px;
}
.rec-label {
font-size: 11.5px;
color: var(--text-secondary);
white-space: nowrap;
flex-shrink: 0;
}
.rec-label-unit {
font-size: 11.5px;
color: var(--text-muted);
flex-shrink: 0;
}
.rec-folder-section {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.rec-folder-path {
font-size: 11.5px;
color: var(--accent);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 260px;
text-decoration: underline;
text-underline-offset: 2px;
}
.rec-folder-path:hover { color: var(--accent-hover); }
.rec-folder-empty {
font-size: 11.5px;
color: var(--text-muted);
font-style: italic;
}
.rec-segment-section {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
.rec-segment-input {
width: 56px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-primary);
border-radius: var(--radius-sm);
padding: 3px 6px;
font-size: 12px;
text-align: center;
}
.rec-segment-input:focus {
outline: none;
border-color: var(--border-focus);
}
.rec-select-section {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.rec-selectall-label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
font-size: 11.5px;
color: var(--text-secondary);
user-select: none;
padding: 3px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: transparent;
transition: all 0.15s;
}
.rec-selectall-label:hover { border-color: var(--red); color: var(--red); }
.rec-selectall-label--on { border-color: var(--red); color: var(--red); background: var(--red-dim); }
.rec-selectall-label--partial { border-color: var(--orange); color: var(--orange); }
.rec-selectall-box { font-size: 14px; line-height: 1; }
.rec-active-badge {
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 8px;
background: var(--red);
color: #fff;
}
.rec-warn {
font-size: 11px;
color: var(--orange);
flex-shrink: 0;
}
/* btn--sm and btn--outline for RecordingBar */
.btn--sm {
padding: 3px 8px;
font-size: 11.5px;
height: auto;
}
.btn--outline {
background: transparent;
border: 1px solid var(--border-bright);
color: var(--text-secondary);
}
.btn--outline:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-dim);
}
/* ========== RECORD CHECKBOX IN STREAM SETTINGS ========== */
.rec-checkbox-label {
color: var(--text-secondary);
flex-wrap: wrap;
row-gap: 4px;
}
.rec-checkbox-label--on {
color: var(--red);
}
.rec-checkbox-label--on input { accent-color: var(--red); }
.rec-warn-inline {
font-size: 11px;
color: var(--orange);
margin-left: 4px;
}
.rec-folder-hint {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
margin-left: 4px;
}