feat: v1.2.0 — auto-reconnect, recording, monitor bounds, WASAPI loopback

- Auto-reconnect: stream retries with exponential backoff (2s→30s max)
  on unexpected exit; stops only when user clicks Stop
- Recording: global RecordingBar with folder picker, segment duration (min),
  per-stream checkbox, Select All; uses FFmpeg tee muxer for simultaneous
  SRT + segmented .ts file output
- Monitor capture: PowerShell System.Windows.Forms.Screen gives exact
  pixel bounds (x/y/width/height); per-monitor deviceName is now unique
  (desktop_monitor_N); gdigrab uses -offset_x/-offset_y/-video_size
- Window capture: windowTitle now explicitly propagated in handleVideoSelect
- System audio: added WASAPI Loopback option (no VB-Audio required),
  existing virtual-audio-capturer kept as fallback
- Reconnecting event forwarded to renderer; status shows "Reconnecting…"
  with attempt count in logs
- IPC: pick-folder (dialog.showOpenDialog) and open-folder (shell.openPath)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
admin
2026-04-22 12:10:39 +03:00
parent 6b0c2ca0ae
commit acbd3b6349
11 changed files with 659 additions and 264 deletions
+78 -54
View File
@@ -26,8 +26,6 @@ function runCommand(cmd, args, timeoutMs = 10000) {
proc.stdout.on('data', d => outBufs.push(d))
proc.stderr.on('data', d => errBufs.push(d))
const finish = () => {
// FFmpeg outputs UTF-8; on Russian Windows system tools may use CP1251,
// but FFmpeg is always UTF-8 — safe to decode as utf8 here.
const stdout = Buffer.concat(outBufs).toString('utf8')
const stderr = Buffer.concat(errBufs).toString('utf8')
resolve({ stdout, stderr })
@@ -38,12 +36,9 @@ function runCommand(cmd, args, timeoutMs = 10000) {
})
}
// ─── Windows — enumerate application windows via PowerShell ───────────────────
// KEY: force UTF-8 output BEFORE any text is written, receive as Buffer,
// then decode as UTF-8 — this fixes Cyrillic/Unicode window titles on Russian Windows.
// ─── Windows — enumerate open windows via PowerShell (UTF-8 safe) ─────────────
function getWindowsWindowsList() {
return new Promise((resolve) => {
// Force UTF-8 console output so Node receives proper Unicode
const ps1 = [
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;',
'$OutputEncoding = [System.Text.Encoding]::UTF8;',
@@ -56,14 +51,12 @@ function getWindowsWindowsList() {
execFile(
'powershell.exe',
['-NonInteractive', '-NoProfile', '-WindowStyle', 'Hidden', '-Command', ps1],
// encoding: 'buffer' — receive raw bytes, we decode manually as UTF-8
{ timeout: 8000, windowsHide: true, encoding: 'buffer' },
(err, stdoutBuf) => {
if (err || !stdoutBuf || !stdoutBuf.length) { resolve([]); return }
// Decode the buffer as UTF-8 (PowerShell wrote UTF-8 BOM-less)
// Strip UTF-8 BOM if present (EF BB BF)
let buf = stdoutBuf
// Strip UTF-8 BOM if present
if (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) buf = buf.slice(3)
const stdout = buf.toString('utf8')
@@ -83,7 +76,57 @@ function getWindowsWindowsList() {
})
}
// ─── Windows — enumerate dShow devices + monitors ─────────────────────────────
// ─── Windows — enumerate monitors with exact pixel bounds ─────────────────────
// Uses System.Windows.Forms.Screen (always available on Windows)
function getWindowsMonitors() {
return new Promise((resolve) => {
const ps1 = [
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;',
'$OutputEncoding = [System.Text.Encoding]::UTF8;',
'Add-Type -AssemblyName System.Windows.Forms;',
'[System.Windows.Forms.Screen]::AllScreens',
'| Sort-Object { $_.Primary } -Descending', // primary first
'| ForEach-Object {',
' $b = $_.Bounds;',
' Write-Output ("{0}|{1}|{2}|{3}|{4}|{5}" -f $_.DeviceName, $b.X, $b.Y, $b.Width, $b.Height, $_.Primary)',
'}'
].join(' ')
execFile(
'powershell.exe',
['-NonInteractive', '-NoProfile', '-WindowStyle', 'Hidden', '-Command', ps1],
{ timeout: 8000, windowsHide: true, encoding: 'buffer' },
(err, stdoutBuf) => {
if (err || !stdoutBuf || !stdoutBuf.length) { resolve([]); return }
let buf = stdoutBuf
if (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) buf = buf.slice(3)
const stdout = buf.toString('utf8')
const monitors = []
for (const line of stdout.trim().split('\n')) {
const parts = line.trim().replace(/\r$/, '').split('|')
if (parts.length < 5) continue
const [deviceName, x, y, w, h, primary] = parts
monitors.push({
label: (deviceName || '').trim().replace(/\\\\.\\/, '').trim(),
bounds: {
x: parseInt(x) || 0,
y: parseInt(y) || 0,
width: parseInt(w) || 1920,
height: parseInt(h) || 1080,
},
primary: primary?.trim().toLowerCase() === 'true'
})
}
resolve(monitors)
}
)
})
}
// ─── Windows — dShow devices + monitors + windows ─────────────────────────────
async function getWindowsDevices() {
const ffmpeg = getFfmpegPath()
const { stderr } = await runCommand(ffmpeg, ['-list_devices', 'true', '-f', 'dshow', '-i', 'dummy'])
@@ -98,31 +141,39 @@ async function getWindowsDevices() {
if (audioMatch) { audio.push({ name: audioMatch[1], deviceName: audioMatch[1], type: 'device' }) }
}
// System audio loopback (works with VB-Audio, Stereo Mix, or virtual-audio-capturer)
// System audio options
audio.push({
name: 'Системный звук (Loopback / Stereo Mix)',
name: 'Системный звук (WASAPI Loopback)',
deviceName: '__wasapi_loopback__',
type: 'system',
description: 'Нативный захват Windows — без стороннего ПО'
})
audio.push({
name: 'Системный звук (VB-Audio / Stereo Mix)',
deviceName: 'virtual-audio-capturer',
type: 'system',
description: 'Захват всего выходного звука'
description: 'Требует VB-Audio Cable или Stereo Mix'
})
// Screen sources: full desktop
// Screen sources
const screenDevices = [
{ name: 'Рабочий стол (весь экран)', type: 'desktop', deviceName: 'desktop' }
]
// Per-monitor entries
// Per-monitor entries with real pixel bounds
const monitors = await getWindowsMonitors()
monitors.forEach((m, i) => {
const label = m.primary ? `${m.label} (основной)` : m.label
screenDevices.push({
name: `Монитор ${i + 1}${m.label ? ': ' + m.label : ''}`,
name: `Монитор ${i + 1}${label ? ': ' + label : ''}${m.bounds.width}×${m.bounds.height}`,
type: 'desktop',
deviceName: 'desktop',
monitorIndex: i
deviceName: `desktop_monitor_${i}`, // unique key per monitor
monitorIndex: i,
monitorBounds: m.bounds // { x, y, width, height }
})
})
// Enumerate open windows — included directly in video sources
// Open application windows
const wins = await getWindowsWindowsList()
const windowDevices = wins.map(w => ({
name: w.title,
@@ -138,22 +189,6 @@ async function getWindowsDevices() {
}
}
async function getWindowsMonitors() {
return new Promise((resolve) => {
const t = setTimeout(() => resolve([]), 5000)
exec('wmic desktopmonitor get Name /format:list', (err, stdout) => {
clearTimeout(t)
if (err || !stdout) { resolve([]); return }
const monitors = []
for (const block of stdout.split(/\r?\n\r?\n/)) {
const m = block.match(/Name=(.+)/)
if (m && m[1].trim()) monitors.push({ label: m[1].trim() })
}
resolve(monitors)
})
})
}
// ─── macOS ────────────────────────────────────────────────────────────────────
async function getMacDevices() {
const ffmpeg = getFfmpegPath()
@@ -176,13 +211,10 @@ async function getMacDevices() {
}
}
// macOS window list via osascript
const wins = await getMacWindowsList()
const windowDevices = wins.map(w => ({
name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title
}))
video.push(...wins.map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title })))
return { video: [...video, ...windowDevices], audio }
return { video, audio }
}
function getMacWindowsList() {
@@ -190,9 +222,8 @@ function getMacWindowsList() {
const script = 'tell application "System Events" to get the name of every window of every process whose visible is true'
execFile('osascript', ['-e', script], { timeout: 5000 }, (err, stdout) => {
if (err || !stdout) { resolve([]); return }
const wins = stdout.trim().split(',').map(s => s.trim()).filter(Boolean)
.map(title => ({ title, type: 'window' }))
resolve(wins)
resolve(stdout.trim().split(',').map(s => s.trim()).filter(Boolean)
.map(title => ({ title, type: 'window' })))
})
})
}
@@ -201,7 +232,6 @@ function getMacWindowsList() {
async function getLinuxDevices() {
const video = [], audio = []
// V4L2 cameras
try {
const { stdout: devList } = await runCommand('ls', ['/dev/video0', '/dev/video1', '/dev/video2', '/dev/video3', '/dev/video4'])
for (const dev of devList.trim().split('\n').filter(Boolean)) {
@@ -212,12 +242,9 @@ async function getLinuxDevices() {
const display = process.env.DISPLAY || ':0'
video.unshift({ name: `Рабочий стол (${display})`, type: 'desktop', devicePath: display })
// Linux windows
const wins = await getLinuxWindowsList()
const windowDevices = wins.map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title }))
video.push(...windowDevices)
video.push(...wins.map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title })))
// PulseAudio / ALSA
try {
const { stdout: paList } = await runCommand('pactl', ['list', 'short', 'sources'])
for (const line of paList.split('\n').filter(Boolean)) {
@@ -243,14 +270,12 @@ function getLinuxWindowsList() {
return new Promise((resolve) => {
execFile('wmctrl', ['-l'], { timeout: 3000 }, (err, stdout) => {
if (err || !stdout) { resolve([]); return }
const wins = stdout.trim().split('\n')
resolve(stdout.trim().split('\n')
.map(line => {
const parts = line.split(/\s+/)
const title = parts.slice(3).join(' ').trim()
return { title, type: 'window' }
return { title: parts.slice(3).join(' ').trim(), type: 'window' }
})
.filter(w => w.title)
resolve(wins)
.filter(w => w.title))
})
})
}
@@ -273,7 +298,6 @@ async function getDevices() {
return result
}
// Still exposed separately for the refresh button
async function getWindows() {
if (process.platform === 'win32') return getWindowsWindowsList()
if (process.platform === 'darwin') return getMacWindowsList()