Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96b099d892 | |||
| e9a9e336da | |||
| acbd3b6349 | |||
| 6b0c2ca0ae |
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"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 *)",
|
||||
"Bash(git commit -m ' *)",
|
||||
"Bash(dir 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(python -c \"import sys,json; r=json.load\\(sys.stdin\\); print\\('Release ID:', r.get\\('id'\\), '| URL:', r.get\\('html_url'\\)\\)\")",
|
||||
"Bash(python -c \"import sys,json; r=json.load\\(sys.stdin\\); print\\('Asset:', r.get\\('name'\\), '| Size:', r.get\\('size'\\), 'bytes'\\)\")",
|
||||
"Bash(git checkout *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+98
-56
@@ -22,21 +22,26 @@ function getFfmpegPath() {
|
||||
function runCommand(cmd, args, timeoutMs = 10000) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
let stdout = '', stderr = ''
|
||||
proc.stdout.on('data', d => stdout += d)
|
||||
proc.stderr.on('data', d => stderr += d)
|
||||
proc.on('close', () => resolve({ stdout, stderr }))
|
||||
const outBufs = [], errBufs = []
|
||||
proc.stdout.on('data', d => outBufs.push(d))
|
||||
proc.stderr.on('data', d => errBufs.push(d))
|
||||
const finish = () => {
|
||||
const stdout = Buffer.concat(outBufs).toString('utf8')
|
||||
const stderr = Buffer.concat(errBufs).toString('utf8')
|
||||
resolve({ stdout, stderr })
|
||||
}
|
||||
proc.on('close', finish)
|
||||
proc.on('error', () => resolve({ stdout: '', stderr: '' }))
|
||||
setTimeout(() => { try { proc.kill() } catch {} ; resolve({ stdout, stderr }) }, timeoutMs)
|
||||
setTimeout(() => { try { proc.kill() } catch {} }, timeoutMs)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Windows — enumerate application windows via PowerShell ───────────────────
|
||||
// Uses execFile to pass arguments as array → no quoting/escaping issues
|
||||
// ─── Windows — enumerate open windows via PowerShell (UTF-8 safe) ─────────────
|
||||
function getWindowsWindowsList() {
|
||||
return new Promise((resolve) => {
|
||||
// Each line: "WindowTitle|||ProcessName"
|
||||
const ps1 = [
|
||||
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;',
|
||||
'$OutputEncoding = [System.Text.Encoding]::UTF8;',
|
||||
'Get-Process',
|
||||
'| Where-Object { $_.MainWindowTitle -ne "" }',
|
||||
'| Sort-Object MainWindowTitle',
|
||||
@@ -46,9 +51,15 @@ function getWindowsWindowsList() {
|
||||
execFile(
|
||||
'powershell.exe',
|
||||
['-NonInteractive', '-NoProfile', '-WindowStyle', 'Hidden', '-Command', ps1],
|
||||
{ timeout: 8000, windowsHide: true },
|
||||
(err, stdout) => {
|
||||
if (err || !stdout) { resolve([]); return }
|
||||
{ timeout: 8000, windowsHide: true, encoding: 'buffer' },
|
||||
(err, stdoutBuf) => {
|
||||
if (err || !stdoutBuf || !stdoutBuf.length) { resolve([]); return }
|
||||
|
||||
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')
|
||||
|
||||
const wins = stdout.trim().split('\n')
|
||||
.map(l => l.trim().replace(/\r$/, ''))
|
||||
.filter(Boolean)
|
||||
@@ -65,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'])
|
||||
@@ -80,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,
|
||||
@@ -120,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()
|
||||
@@ -158,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() {
|
||||
@@ -172,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' })))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -183,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)) {
|
||||
@@ -194,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)) {
|
||||
@@ -225,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))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -255,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()
|
||||
|
||||
+145
-112
@@ -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.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 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,20 +335,16 @@ 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
|
||||
const lat = (latency || 200) * 1000
|
||||
const srtMode = mode || 'listener'
|
||||
|
||||
let srtUrl
|
||||
@@ -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
|
||||
})
|
||||
|
||||
+100
-9
@@ -1,8 +1,9 @@
|
||||
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')
|
||||
const deviceManager = require('./devices')
|
||||
const ffmpegManager = require('./ffmpeg')
|
||||
const deviceManager = require('./devices')
|
||||
const remoteClient = require('./remote')
|
||||
|
||||
// ─── Icon helpers ──────────────────────────────────────────────────────────────
|
||||
// Draw a pixel into an RGBA buffer
|
||||
@@ -124,7 +125,12 @@ 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 },
|
||||
remote: { enabled: false, server: '', port: '', token: require('./remote').RemoteClient.generateToken(), machineName: '' }
|
||||
}
|
||||
}
|
||||
|
||||
function saveConfig(config) {
|
||||
@@ -323,6 +329,42 @@ 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 {}
|
||||
})
|
||||
|
||||
// ─── Remote control IPC ───────────────────────────────────────────────────────
|
||||
ipcMain.handle('remote-connect', async (_, { server, port, token, machineName }) => {
|
||||
try {
|
||||
remoteClient.connect({ server, port, token, machineName })
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('remote-disconnect', () => {
|
||||
remoteClient.disconnect()
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('remote-get-url', (_, { server, port }) => {
|
||||
return require('./remote').buildWsUrl(server, port)
|
||||
})
|
||||
|
||||
ipcMain.handle('generate-token', () => {
|
||||
return require('./remote').RemoteClient.generateToken()
|
||||
})
|
||||
|
||||
ipcMain.handle('minimize-window', () => {
|
||||
mainWindow && mainWindow.minimize()
|
||||
})
|
||||
@@ -335,21 +377,70 @@ 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)
|
||||
})
|
||||
|
||||
// ─── Remote client events ─────────────────────────────────────────────────────
|
||||
remoteClient.on('status', (data) => {
|
||||
mainWindow && mainWindow.webContents.send('remote-status', data)
|
||||
})
|
||||
|
||||
// Server requested device list → fetch and send back
|
||||
remoteClient.on('request_devices', async () => {
|
||||
try {
|
||||
const devices = await deviceManager.getDevices()
|
||||
remoteClient.sendDevices(devices)
|
||||
} catch {}
|
||||
})
|
||||
|
||||
// Server sent a command → execute locally
|
||||
remoteClient.on('command', async ({ action, config, id, changes }) => {
|
||||
try {
|
||||
if (action === 'start_stream') {
|
||||
// Update UI first
|
||||
mainWindow && mainWindow.webContents.send('remote-command', { action: 'start_stream', config })
|
||||
const result = await ffmpegManager.startStream(config)
|
||||
updateTrayMenu(ffmpegManager.getActiveCount())
|
||||
remoteClient.sendStreamStatus(config.id, 'running')
|
||||
} else if (action === 'stop_stream') {
|
||||
ffmpegManager.stopStream(id)
|
||||
updateTrayMenu(ffmpegManager.getActiveCount())
|
||||
remoteClient.sendStreamStatus(id, 'idle')
|
||||
mainWindow && mainWindow.webContents.send('remote-command', { action: 'stop_stream', id })
|
||||
} else if (action === 'update_stream') {
|
||||
mainWindow && mainWindow.webContents.send('remote-command', { action: 'update_stream', id, changes })
|
||||
} else if (action === 'stop_all') {
|
||||
ffmpegManager.stopAllStreams()
|
||||
updateTrayMenu(0)
|
||||
mainWindow && mainWindow.webContents.send('all-streams-stopped')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Remote] Command error:', e)
|
||||
}
|
||||
})
|
||||
|
||||
// Forward stream events to remote server
|
||||
ffmpegManager.on('ended', (data) => {
|
||||
remoteClient.sendStreamStatus(data.id, 'idle')
|
||||
})
|
||||
ffmpegManager.on('error', (data) => {
|
||||
remoteClient.sendStreamStatus(data.id, 'error')
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
|
||||
+24
-11
@@ -2,7 +2,7 @@ const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Config
|
||||
getConfig: () => ipcRenderer.invoke('get-config'),
|
||||
getConfig: () => ipcRenderer.invoke('get-config'),
|
||||
saveConfig: (config) => ipcRenderer.invoke('save-config', config),
|
||||
|
||||
// Devices
|
||||
@@ -10,25 +10,38 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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'),
|
||||
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'),
|
||||
stopTolbek: () => ipcRenderer.invoke('stop-tolbek'),
|
||||
|
||||
// File system helpers
|
||||
pickFolder: () => ipcRenderer.invoke('pick-folder'),
|
||||
openFolder: (p) => ipcRenderer.invoke('open-folder', p),
|
||||
|
||||
// Remote control
|
||||
remoteConnect: (cfg) => ipcRenderer.invoke('remote-connect', cfg),
|
||||
remoteDisconnect: () => ipcRenderer.invoke('remote-disconnect'),
|
||||
remoteGetUrl: (cfg) => ipcRenderer.invoke('remote-get-url', cfg),
|
||||
generateToken: () => ipcRenderer.invoke('generate-token'),
|
||||
|
||||
// Window controls
|
||||
minimizeWindow: () => ipcRenderer.invoke('minimize-window'),
|
||||
hideWindow: () => ipcRenderer.invoke('hide-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()),
|
||||
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()),
|
||||
onRemoteStatus: (cb) => ipcRenderer.on('remote-status', (_, d) => cb(d)),
|
||||
onRemoteCommand: (cb) => ipcRenderer.on('remote-command', (_, d) => cb(d)),
|
||||
|
||||
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
'use strict'
|
||||
const { EventEmitter } = require('events')
|
||||
const { randomUUID } = require('crypto')
|
||||
const WebSocket = require('ws')
|
||||
|
||||
// ─── URL builder ──────────────────────────────────────────────────────────────
|
||||
// Accepts any of:
|
||||
// srt.queo.ru → wss://srt.queo.ru:443
|
||||
// srt.queo.ru:8080 → ws://srt.queo.ru:8080 (non-443 = assume no SSL)
|
||||
// 192.168.1.5 → ws://192.168.1.5:443
|
||||
// 192.168.1.5:3000 → ws://192.168.1.5:3000
|
||||
// https://srt.queo.ru → wss://srt.queo.ru:443
|
||||
// http://pi.local:3000 → ws://pi.local:3000
|
||||
function buildWsUrl(server, port) {
|
||||
let s = (server || '').trim()
|
||||
|
||||
// Already has explicit ws:// or wss:// — use as-is (port ignored)
|
||||
if (/^wss?:\/\//i.test(s)) return s
|
||||
|
||||
// Strip http(s):// prefix, remember the scheme
|
||||
let forceSecure = null
|
||||
if (/^https:\/\//i.test(s)) { s = s.replace(/^https:\/\//i, ''); forceSecure = true }
|
||||
else if (/^http:\/\//i.test(s)) { s = s.replace(/^http:\/\//i, ''); forceSecure = false }
|
||||
|
||||
// Parse host and optional inline port
|
||||
let host = s
|
||||
let resolvedPort = parseInt(port) || 443
|
||||
|
||||
const colonIdx = s.lastIndexOf(':')
|
||||
if (colonIdx !== -1) {
|
||||
const maybePort = parseInt(s.slice(colonIdx + 1))
|
||||
if (maybePort > 0 && maybePort < 65536) {
|
||||
host = s.slice(0, colonIdx)
|
||||
resolvedPort = maybePort
|
||||
}
|
||||
}
|
||||
|
||||
// Choose protocol: wss for port 443 or explicit https, ws otherwise
|
||||
const secure = forceSecure !== null ? forceSecure : resolvedPort === 443
|
||||
const proto = secure ? 'wss' : 'ws'
|
||||
|
||||
return `${proto}://${host}:${resolvedPort}`
|
||||
}
|
||||
|
||||
// ─── RemoteClient ─────────────────────────────────────────────────────────────
|
||||
class RemoteClient extends EventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
this.ws = null
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this._manualClose = false
|
||||
this._retryTimer = null
|
||||
this._retryCount = 0
|
||||
this._pingInterval = null
|
||||
|
||||
this.serverUrl = null
|
||||
this.token = null
|
||||
this.machineName = null
|
||||
}
|
||||
|
||||
// ── Connect ────────────────────────────────────────────────────────────────
|
||||
connect({ server, port, token, machineName }) {
|
||||
this._manualClose = false
|
||||
this.token = token
|
||||
this.machineName = machineName || require('os').hostname()
|
||||
this.serverUrl = buildWsUrl(server, port)
|
||||
|
||||
console.log('[Remote] Connecting to', this.serverUrl)
|
||||
this._doConnect()
|
||||
}
|
||||
|
||||
_doConnect() {
|
||||
if (this.ws) {
|
||||
try { this.ws.terminate() } catch {}
|
||||
this.ws = null
|
||||
}
|
||||
|
||||
this.connecting = true
|
||||
this.emit('status', { status: 'connecting', url: this.serverUrl })
|
||||
|
||||
const ws = new WebSocket(this.serverUrl, {
|
||||
handshakeTimeout: 10000,
|
||||
// Allow self-signed certs on local network
|
||||
rejectUnauthorized: !this.serverUrl.includes('192.168.') &&
|
||||
!this.serverUrl.includes('10.') &&
|
||||
!this.serverUrl.includes('172.') &&
|
||||
!this.serverUrl.includes('localhost') &&
|
||||
!this.serverUrl.includes('.local')
|
||||
})
|
||||
this.ws = ws
|
||||
|
||||
ws.on('open', () => {
|
||||
this.connected = false // becomes true after auth_ok
|
||||
this.connecting = false
|
||||
this._retryCount = 0
|
||||
|
||||
// Send auth immediately
|
||||
this._send({ type: 'auth', token: this.token, machineName: this.machineName, version: '2.0.0' })
|
||||
|
||||
// Keepalive ping every 25s
|
||||
this._pingInterval = setInterval(() => this._send({ type: 'ping' }), 25000)
|
||||
})
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw)
|
||||
this._handleMessage(msg)
|
||||
} catch (e) {
|
||||
console.warn('[Remote] Invalid message:', raw)
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
this._cleanup()
|
||||
if (!this._manualClose) {
|
||||
const delay = Math.min(3000 * Math.pow(1.5, this._retryCount), 60000)
|
||||
this._retryCount++
|
||||
console.log(`[Remote] Disconnected (${code}), retrying in ${(delay/1000).toFixed(1)}s`)
|
||||
this.emit('status', { status: 'disconnected', retryIn: delay })
|
||||
this._retryTimer = setTimeout(() => this._doConnect(), delay)
|
||||
} else {
|
||||
this.emit('status', { status: 'disconnected' })
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('[Remote] WS error:', err.message)
|
||||
this.emit('status', { status: 'error', error: err.message })
|
||||
// 'close' fires after 'error', so reconnect is handled there
|
||||
})
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._manualClose = true
|
||||
this._cleanup()
|
||||
if (this.ws) {
|
||||
try { this.ws.terminate() } catch {}
|
||||
this.ws = null
|
||||
}
|
||||
this.emit('status', { status: 'disconnected' })
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
clearInterval(this._pingInterval)
|
||||
clearTimeout(this._retryTimer)
|
||||
this._pingInterval = null
|
||||
this._retryTimer = null
|
||||
}
|
||||
|
||||
// ── Incoming messages ──────────────────────────────────────────────────────
|
||||
_handleMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'auth_ok':
|
||||
this.connected = true
|
||||
console.log('[Remote] Authenticated, server name:', msg.serverName)
|
||||
this.emit('status', { status: 'connected', serverName: msg.serverName })
|
||||
// Server may request initial state right away
|
||||
this.emit('request_devices')
|
||||
break
|
||||
|
||||
case 'auth_fail':
|
||||
console.error('[Remote] Auth failed:', msg.reason)
|
||||
this.emit('status', { status: 'auth_failed', reason: msg.reason })
|
||||
this.disconnect()
|
||||
break
|
||||
|
||||
case 'get_devices':
|
||||
this.emit('request_devices')
|
||||
break
|
||||
|
||||
case 'start_stream':
|
||||
this.emit('command', { action: 'start_stream', config: msg.config })
|
||||
break
|
||||
|
||||
case 'stop_stream':
|
||||
this.emit('command', { action: 'stop_stream', id: msg.id })
|
||||
break
|
||||
|
||||
case 'update_stream':
|
||||
this.emit('command', { action: 'update_stream', id: msg.id, changes: msg.changes })
|
||||
break
|
||||
|
||||
case 'stop_all':
|
||||
this.emit('command', { action: 'stop_all' })
|
||||
break
|
||||
|
||||
case 'pong':
|
||||
break // keepalive response
|
||||
|
||||
default:
|
||||
console.log('[Remote] Unknown message type:', msg.type)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Outgoing messages ──────────────────────────────────────────────────────
|
||||
_send(obj) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try { this.ws.send(JSON.stringify(obj)) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
sendDevices(devices) {
|
||||
this._send({ type: 'devices', devices })
|
||||
}
|
||||
|
||||
sendStreamStatus(id, status) {
|
||||
this._send({ type: 'stream_status', id, status })
|
||||
}
|
||||
|
||||
sendAllStatus(streams) {
|
||||
this._send({ type: 'all_status', streams })
|
||||
}
|
||||
|
||||
sendLog(id, text) {
|
||||
// Throttle logs — send only every ~500ms or on errors
|
||||
this._send({ type: 'log', id, text })
|
||||
}
|
||||
|
||||
// ── Static helper ──────────────────────────────────────────────────────────
|
||||
static generateToken() {
|
||||
return randomUUID()
|
||||
}
|
||||
|
||||
static buildWsUrl(server, port) {
|
||||
return buildWsUrl(server, port)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RemoteClient()
|
||||
module.exports.RemoteClient = RemoteClient
|
||||
module.exports.buildWsUrl = buildWsUrl
|
||||
Generated
+25
-3
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "srt-streamer",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "srt-streamer",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"fluent-ffmpeg": "^2.1.3"
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -6026,6 +6027,27 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "15.1.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "srt-streamer",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"description": "Cross-platform SRT multi-stream sender with system tray",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
@@ -14,7 +14,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"fluent-ffmpeg": "^2.1.3"
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
+89
-63
@@ -2,6 +2,8 @@ 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'
|
||||
import RemoteSettings from './components/RemoteSettings.jsx'
|
||||
|
||||
const ipc = window.electronAPI || null
|
||||
|
||||
@@ -19,6 +21,7 @@ const DEFAULT_STREAM = () => ({
|
||||
videoBitrate: '4000k',
|
||||
audioBitrate: '128k',
|
||||
hwAccel: 'auto',
|
||||
record: false,
|
||||
status: 'idle'
|
||||
})
|
||||
|
||||
@@ -30,6 +33,9 @@ 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 })
|
||||
const [remote, setRemote] = useState({ enabled: false, server: '', port: '', token: '', machineName: '' })
|
||||
|
||||
// Load config and devices on mount
|
||||
useEffect(() => {
|
||||
@@ -37,16 +43,14 @@ 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)
|
||||
})
|
||||
|
||||
ipc.getDevices().then(devs => {
|
||||
setDevices(devs)
|
||||
setDevicesLoaded(true)
|
||||
if (config.tolbek) setTolbek(config.tolbek)
|
||||
if (config.recording) setRecording(config.recording)
|
||||
if (config.remote) setRemote(config.remote)
|
||||
})
|
||||
|
||||
ipc.getDevices().then(devs => { setDevices(devs); setDevicesLoaded(true) })
|
||||
ipc.getWindows().then(wins => setWindows(wins))
|
||||
}, [])
|
||||
|
||||
@@ -54,27 +58,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 +96,33 @@ 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,
|
||||
remote
|
||||
}
|
||||
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 +134,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 +155,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))
|
||||
}, [])
|
||||
|
||||
const handleMinimize = () => ipc && ipc.minimizeWindow()
|
||||
const handleHide = () => ipc && ipc.hideWindow()
|
||||
// Toggle record flag for all streams
|
||||
const setAllRecord = useCallback((val) => {
|
||||
setStreams(prev => prev.map(s => ({ ...s, record: val })))
|
||||
}, [])
|
||||
|
||||
const activeCount = streams.filter(s => s.status === 'running' || s.status === 'connecting').length
|
||||
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 +178,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">−</button>
|
||||
@@ -161,26 +188,17 @@ 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 === 'remote' ? 'active' : ''}`} onClick={() => setActiveTab('remote')}>
|
||||
Remote {remote.enabled && <span className={`tab-badge ${remote.enabled ? 'tab-badge--green' : ''}`}>●</span>}
|
||||
</button>
|
||||
<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 +206,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 +230,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 +238,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 +247,15 @@ export default function App() {
|
||||
)}
|
||||
|
||||
{activeTab === 'tolbek' && (
|
||||
<TolbekSettings
|
||||
config={tolbek}
|
||||
onChange={setTolbek}
|
||||
ipc={ipc}
|
||||
/>
|
||||
<TolbekSettings config={tolbek} onChange={setTolbek} ipc={ipc} />
|
||||
)}
|
||||
|
||||
{activeTab === 'remote' && (
|
||||
<RemoteSettings config={remote} onChange={setRemote} ipc={ipc} />
|
||||
)}
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
<LogPanel
|
||||
logs={logs}
|
||||
streams={streams}
|
||||
onClear={() => setLogs([])}
|
||||
/>
|
||||
<LogPanel logs={logs} streams={streams} onClear={() => setLogs([])} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
|
||||
const STATUS_LABEL = {
|
||||
disconnected: 'Не подключено',
|
||||
connecting: 'Подключение…',
|
||||
connected: 'Подключено',
|
||||
error: 'Ошибка',
|
||||
auth_failed: 'Ошибка авторизации',
|
||||
}
|
||||
const STATUS_CLASS = {
|
||||
disconnected: 'remote-status--off',
|
||||
connecting: 'remote-status--connecting',
|
||||
connected: 'remote-status--on',
|
||||
error: 'remote-status--error',
|
||||
auth_failed: 'remote-status--error',
|
||||
}
|
||||
|
||||
export default function RemoteSettings({ config, onChange, ipc }) {
|
||||
const [status, setStatus] = useState('disconnected')
|
||||
const [statusInfo, setStatusInfo] = useState(null)
|
||||
const [previewUrl, setPreviewUrl] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const debounceRef = useRef(null)
|
||||
|
||||
const { server = '', port = '', token = '', machineName = '', enabled = false } = config
|
||||
|
||||
// Subscribe to remote status events
|
||||
useEffect(() => {
|
||||
if (!ipc) return
|
||||
ipc.onRemoteStatus(({ status: s, ...rest }) => {
|
||||
setStatus(s)
|
||||
setStatusInfo(rest)
|
||||
})
|
||||
return () => ipc.removeAllListeners('remote-status')
|
||||
}, [ipc])
|
||||
|
||||
// Build URL preview when server/port changes
|
||||
useEffect(() => {
|
||||
if (!ipc || !server) { setPreviewUrl(''); return }
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const url = await ipc.remoteGetUrl({ server, port })
|
||||
setPreviewUrl(url)
|
||||
}, 400)
|
||||
}, [server, port, ipc])
|
||||
|
||||
// Auto-connect on mount if enabled
|
||||
useEffect(() => {
|
||||
if (!ipc || !enabled || !server || !token) return
|
||||
handleConnect()
|
||||
}, []) // eslint-disable-line
|
||||
|
||||
async function handleConnect() {
|
||||
if (!ipc || !server || !token) return
|
||||
await ipc.remoteConnect({ server, port, token, machineName })
|
||||
onChange({ ...config, enabled: true })
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
if (!ipc) return
|
||||
await ipc.remoteDisconnect()
|
||||
onChange({ ...config, enabled: false })
|
||||
}
|
||||
|
||||
async function handleGenerateToken() {
|
||||
if (!ipc) return
|
||||
const newToken = await ipc.generateToken()
|
||||
onChange({ ...config, token: newToken })
|
||||
}
|
||||
|
||||
function handleCopyToken() {
|
||||
navigator.clipboard.writeText(token).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
})
|
||||
}
|
||||
|
||||
const isConnected = status === 'connected'
|
||||
const isConnecting = status === 'connecting'
|
||||
const canConnect = server && token && !isConnected && !isConnecting
|
||||
|
||||
return (
|
||||
<div className="remote-settings">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">🌐 Удалённое управление</h2>
|
||||
<p className="section-desc">
|
||||
Подключите приложение к центральному серверу для управления через веб-интерфейс.
|
||||
Приложение само устанавливает соединение — NAT и проброс портов не нужны.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className={`remote-status-bar ${STATUS_CLASS[status] || 'remote-status--off'}`}>
|
||||
<span className="remote-status-dot" />
|
||||
<span className="remote-status-label">{STATUS_LABEL[status] || status}</span>
|
||||
{isConnected && statusInfo?.serverName && (
|
||||
<span className="remote-status-server">→ {statusInfo.serverName}</span>
|
||||
)}
|
||||
{status === 'disconnected' && statusInfo?.retryIn && (
|
||||
<span className="remote-status-retry">
|
||||
повтор через {(statusInfo.retryIn / 1000).toFixed(0)}с
|
||||
</span>
|
||||
)}
|
||||
{status === 'auth_failed' && statusInfo?.reason && (
|
||||
<span className="remote-status-retry">{statusInfo.reason}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
{/* Server */}
|
||||
<div className="settings-group">
|
||||
<div className="group-title">Сервер</div>
|
||||
|
||||
<div className="fields-row">
|
||||
<div className="field field--grow">
|
||||
<label className="field-label">Адрес сервера</label>
|
||||
<input
|
||||
className="field-input"
|
||||
type="text"
|
||||
value={server}
|
||||
onChange={e => onChange({ ...config, server: e.target.value })}
|
||||
placeholder="srt.queo.ru или 192.168.1.100"
|
||||
disabled={isConnected || isConnecting}
|
||||
/>
|
||||
</div>
|
||||
<div className="field" style={{ width: 90 }}>
|
||||
<label className="field-label">Порт</label>
|
||||
<input
|
||||
className="field-input"
|
||||
type="number"
|
||||
min="1" max="65535"
|
||||
value={port}
|
||||
onChange={e => onChange({ ...config, port: e.target.value })}
|
||||
placeholder="443"
|
||||
disabled={isConnected || isConnecting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewUrl && (
|
||||
<div className="srt-preview">
|
||||
<span className="srt-preview-label">WebSocket URL:</span>
|
||||
<code className="srt-url">{previewUrl}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Token */}
|
||||
<div className="settings-group">
|
||||
<div className="group-title">Токен авторизации</div>
|
||||
<p className="info-text">
|
||||
Введите этот токен в веб-интерфейсе при добавлении нового агента.
|
||||
</p>
|
||||
<div className="remote-token-row">
|
||||
<code className="remote-token">{token || '—'}</code>
|
||||
<button
|
||||
className="btn btn--small"
|
||||
onClick={handleCopyToken}
|
||||
disabled={!token}
|
||||
title="Скопировать токен"
|
||||
>
|
||||
{copied ? '✓ Скопировано' : '📋 Копировать'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--small btn--danger-outline"
|
||||
onClick={handleGenerateToken}
|
||||
disabled={isConnected}
|
||||
title="Сгенерировать новый токен (потребуется повторная авторизация на сервере)"
|
||||
>
|
||||
↺ Новый
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Machine name */}
|
||||
<div className="settings-group">
|
||||
<div className="group-title">Имя машины</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Отображается в веб-интерфейсе</label>
|
||||
<input
|
||||
className="field-input"
|
||||
type="text"
|
||||
value={machineName}
|
||||
onChange={e => onChange({ ...config, machineName: e.target.value })}
|
||||
placeholder="Студия / Офис / Выездной"
|
||||
disabled={isConnected}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="remote-actions">
|
||||
{!isConnected ? (
|
||||
<button
|
||||
className="btn btn--start"
|
||||
onClick={handleConnect}
|
||||
disabled={!canConnect}
|
||||
>
|
||||
🔗 Подключиться
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn--stop" onClick={handleDisconnect}>
|
||||
✕ Отключиться
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="settings-group">
|
||||
<div className="group-title">Как это работает</div>
|
||||
<div className="tolbek-modes-info">
|
||||
<div className="mode-row">
|
||||
<span className="mode-tag">Агент</span>
|
||||
<span className="mode-desc">
|
||||
SRT Streamer подключается к серверу и остаётся доступным для управления.
|
||||
Работает через NAT без проброса портов.
|
||||
</span>
|
||||
</div>
|
||||
<div className="mode-row">
|
||||
<span className="mode-tag">Сервер</span>
|
||||
<span className="mode-desc">
|
||||
Веб-приложение на <em>{server || 'вашем домене'}</em> — показывает все подключённые
|
||||
агенты, их устройства, позволяет запускать и останавливать потоки.
|
||||
</span>
|
||||
</div>
|
||||
<div className="mode-row">
|
||||
<span className="mode-tag">Локально</span>
|
||||
<span className="mode-desc">
|
||||
Без подключения приложение работает в обычном режиме —
|
||||
удалённое управление опционально.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,20 +2,22 @@ import React, { useState } from 'react'
|
||||
import StreamSettings from './StreamSettings.jsx'
|
||||
|
||||
const STATUS_LABELS = {
|
||||
idle: 'Idle',
|
||||
connecting: 'Connecting…',
|
||||
running: 'Live',
|
||||
error: 'Error'
|
||||
idle: 'Idle',
|
||||
connecting: 'Connecting…',
|
||||
running: 'Live',
|
||||
error: 'Error',
|
||||
reconnecting: 'Reconnecting…'
|
||||
}
|
||||
|
||||
const STATUS_CLASS = {
|
||||
idle: 'status--idle',
|
||||
connecting: 'status--connecting',
|
||||
running: 'status--running',
|
||||
error: 'status--error'
|
||||
idle: 'status--idle',
|
||||
connecting: 'status--connecting',
|
||||
running: 'status--running',
|
||||
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>
|
||||
|
||||
@@ -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 } })
|
||||
@@ -134,12 +134,13 @@ export default function StreamSettings({ stream, devices, windows, devicesLoaded
|
||||
const dev = allOpts.find(d => d.deviceName === deviceName || d.name === deviceName)
|
||||
if (dev) {
|
||||
updateVideo({
|
||||
deviceName: dev.deviceName || dev.name,
|
||||
type: dev.type,
|
||||
deviceIndex: dev.deviceIndex,
|
||||
devicePath: dev.devicePath,
|
||||
windowTitle: dev.windowTitle,
|
||||
screenIndex: dev.monitorIndex,
|
||||
deviceName: dev.deviceName || dev.name,
|
||||
type: dev.type,
|
||||
deviceIndex: dev.deviceIndex,
|
||||
devicePath: dev.devicePath,
|
||||
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>
|
||||
|
||||
@@ -761,3 +761,252 @@ 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;
|
||||
}
|
||||
|
||||
/* ========== REMOTE SETTINGS ========== */
|
||||
.remote-settings {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.remote-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 16px;
|
||||
font-size: 12.5px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-section);
|
||||
}
|
||||
|
||||
.remote-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.remote-status--off .remote-status-dot { background: var(--text-muted); }
|
||||
.remote-status--on .remote-status-dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.remote-status--connecting .remote-status-dot {
|
||||
background: var(--orange);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
.remote-status--error .remote-status-dot { background: var(--red); }
|
||||
|
||||
.remote-status--on { border-color: var(--green); background: var(--green-dim); }
|
||||
.remote-status--error { border-color: var(--red); background: var(--red-dim); }
|
||||
.remote-status--connecting { border-color: var(--orange); }
|
||||
|
||||
.remote-status-label { font-weight: 600; }
|
||||
.remote-status-server { color: var(--text-muted); font-size: 11.5px; }
|
||||
.remote-status-retry { color: var(--text-muted); font-size: 11px; margin-left: auto; }
|
||||
|
||||
/* Token row */
|
||||
.remote-token-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.remote-token {
|
||||
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-code);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.btn--danger-outline {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||
color: var(--red);
|
||||
}
|
||||
.btn--danger-outline:hover:not(:disabled) {
|
||||
background: var(--red-dim);
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
/* Remote actions */
|
||||
.remote-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user