From acbd3b63491d4c800820896297b2984e1f4cbf11 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 22 Apr 2026 12:10:39 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20v1.2.0=20=E2=80=94=20auto-reconnect,=20?= =?UTF-8?q?recording,=20monitor=20bounds,=20WASAPI=20loopback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/settings.local.json | 32 ++++ electron/devices.js | 132 ++++++++------- electron/ffmpeg.js | 257 +++++++++++++++++------------- electron/main.js | 29 +++- electron/preload.js | 27 ++-- package.json | 2 +- src/App.jsx | 141 ++++++++-------- src/components/RecordingBar.jsx | 87 ++++++++++ src/components/StreamCard.jsx | 21 +-- src/components/StreamSettings.jsx | 35 +++- src/styles/App.css | 160 +++++++++++++++++++ 11 files changed, 659 insertions(+), 264 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 src/components/RecordingBar.jsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..ba433ba --- /dev/null +++ b/.claude/settings.local.json @@ -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 *)" + ] + } +} diff --git a/electron/devices.js b/electron/devices.js index 8f52005..ccd26e8 100644 --- a/electron/devices.js +++ b/electron/devices.js @@ -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() diff --git a/electron/ffmpeg.js b/electron/ffmpeg.js index 3c13d30..56b9e9e 100644 --- a/electron/ffmpeg.js +++ b/electron/ffmpeg.js @@ -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 }) diff --git a/electron/main.js b/electron/main.js index 1bf1f1b..c559cac 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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(() => { diff --git a/electron/preload.js b/electron/preload.js index bc4b3ac..85c1f41 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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,30 @@ 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), // 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()), removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel) }) diff --git a/package.json b/package.json index 86244fc..94a459f 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/App.jsx b/src/App.jsx index 6451c74..3c44386 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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) - }) - - ipc.getDevices().then(devs => { - setDevices(devs) - setDevicesLoaded(true) + if (config.tolbek) setTolbek(config.tolbek) + if (config.recording) setRecording(config.recording) }) + 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)) }, []) - 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 (
@@ -149,9 +174,7 @@ export default function App() {
SRT Streamer - {activeCount > 0 && ( - {activeCount} active - )} + {activeCount > 0 && {activeCount} active}
@@ -161,26 +184,14 @@ export default function App() { {/* Tab Bar */}
- - -
+ {/* Global recording bar — shown only on Streams tab */} + {activeTab === 'streams' && ( + + )} + {/* 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} /> ))} -
diff --git a/src/components/RecordingBar.jsx b/src/components/RecordingBar.jsx new file mode 100644 index 0000000..5861cb3 --- /dev/null +++ b/src/components/RecordingBar.jsx @@ -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 ( +
+ {/* Folder picker */} +
+ 🎬 Запись: + + {recording.folder ? ( + + {recording.folder} + + ) : ( + не выбрана + )} +
+ + {/* Segment duration */} +
+ + onChange({ ...recording, segmentMinutes: Math.max(1, parseInt(e.target.value) || 60) })} + title="Длительность файла в минутах" + /> + мин +
+ + {/* Select all / none */} +
+ + {recordingCount > 0 && ( + + {recordingCount} / {totalCount} + + )} +
+ + {!recording.folder && recordingCount > 0 && ( + ⚠ Выберите папку + )} +
+ ) +} diff --git a/src/components/StreamCard.jsx b/src/components/StreamCard.jsx index e80c49e..1570fa4 100644 --- a/src/components/StreamCard.jsx +++ b/src/components/StreamCard.jsx @@ -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} /> )}
diff --git a/src/components/StreamSettings.jsx b/src/components/StreamSettings.jsx index f08ee1d..3c425ab 100644 --- a/src/components/StreamSettings.jsx +++ b/src/components/StreamSettings.jsx @@ -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 )}
+ {/* Recording checkbox */} +
+
Запись
+ +
+ {/* Audio */}
Аудиоисточник
diff --git a/src/styles/App.css b/src/styles/App.css index 122f53a..0adcece 100644 --- a/src/styles/App.css +++ b/src/styles/App.css @@ -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; +}