Files
srt-streamer/electron/devices.js
admin acbd3b6349 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>
2026-04-22 12:10:39 +03:00

308 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict'
const { spawn, exec, execFile } = require('child_process')
const path = require('path')
const fs = require('fs')
let ffmpegPath = null
function getFfmpegPath() {
if (ffmpegPath) return ffmpegPath
const resourcesPath = process.resourcesPath || path.join(__dirname, '..')
const candidates = [
path.join(resourcesPath, 'ffmpeg-bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
path.join(__dirname, '..', 'ffmpeg-bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
]
for (const p of candidates) {
if (fs.existsSync(p)) { ffmpegPath = p; return p }
}
ffmpegPath = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
return ffmpegPath
}
function runCommand(cmd, args, timeoutMs = 10000) {
return new Promise((resolve) => {
const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] })
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 {} }, timeoutMs)
})
}
// ─── Windows — enumerate open windows via PowerShell (UTF-8 safe) ─────────────
function getWindowsWindowsList() {
return new Promise((resolve) => {
const ps1 = [
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;',
'$OutputEncoding = [System.Text.Encoding]::UTF8;',
'Get-Process',
'| Where-Object { $_.MainWindowTitle -ne "" }',
'| Sort-Object MainWindowTitle',
'| ForEach-Object { Write-Output ($_.MainWindowTitle + "|||" + $_.ProcessName) }'
].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
// 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)
.map(l => {
const sep = l.indexOf('|||')
const title = sep !== -1 ? l.slice(0, sep).trim() : l.trim()
const proc = sep !== -1 ? l.slice(sep + 3).trim() : ''
return { title, processName: proc, type: 'window' }
})
.filter(w => w.title && w.title.length > 0)
resolve(wins)
}
)
})
}
// ─── 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'])
const video = []
const audio = []
for (const line of stderr.split('\n')) {
const videoMatch = line.match(/"([^"]+)"\s*\(video\)/)
if (videoMatch) { video.push({ name: videoMatch[1], deviceName: videoMatch[1], type: 'device' }); continue }
const audioMatch = line.match(/"([^"]+)"\s*\(audio\)/)
if (audioMatch) { audio.push({ name: audioMatch[1], deviceName: audioMatch[1], type: 'device' }) }
}
// System audio options
audio.push({
name: 'Системный звук (WASAPI Loopback)',
deviceName: '__wasapi_loopback__',
type: 'system',
description: 'Нативный захват Windows — без стороннего ПО'
})
audio.push({
name: 'Системный звук (VB-Audio / Stereo Mix)',
deviceName: 'virtual-audio-capturer',
type: 'system',
description: 'Требует VB-Audio Cable или Stereo Mix'
})
// Screen sources
const screenDevices = [
{ name: 'Рабочий стол (весь экран)', type: 'desktop', deviceName: 'desktop' }
]
// 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}${label ? ': ' + label : ''}${m.bounds.width}×${m.bounds.height}`,
type: 'desktop',
deviceName: `desktop_monitor_${i}`, // unique key per monitor
monitorIndex: i,
monitorBounds: m.bounds // { x, y, width, height }
})
})
// Open application windows
const wins = await getWindowsWindowsList()
const windowDevices = wins.map(w => ({
name: w.title,
deviceName: w.title,
processName: w.processName,
type: 'window',
windowTitle: w.title
}))
return {
video: [...screenDevices, ...windowDevices, ...video],
audio
}
}
// ─── macOS ────────────────────────────────────────────────────────────────────
async function getMacDevices() {
const ffmpeg = getFfmpegPath()
const { stderr } = await runCommand(ffmpeg, ['-f', 'avfoundation', '-list_devices', 'true', '-i', ''])
const video = [], audio = []
let section = null
for (const line of stderr.split('\n')) {
if (line.includes('AVFoundation video devices')) { section = 'video'; continue }
if (line.includes('AVFoundation audio devices')) { section = 'audio'; continue }
const m = line.match(/\[(\d+)\]\s+(.+)/)
if (!m) continue
const [, index, name] = m
if (section === 'video') {
const isScreen = /screen|capture|display/i.test(name)
video.push({ name: name.trim(), deviceIndex: index, type: isScreen ? 'desktop' : 'device' })
} else if (section === 'audio') {
audio.push({ name: name.trim(), deviceIndex: index, type: 'device' })
}
}
const wins = await getMacWindowsList()
video.push(...wins.map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title })))
return { video, audio }
}
function getMacWindowsList() {
return new Promise((resolve) => {
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 }
resolve(stdout.trim().split(',').map(s => s.trim()).filter(Boolean)
.map(title => ({ title, type: 'window' })))
})
})
}
// ─── Linux ────────────────────────────────────────────────────────────────────
async function getLinuxDevices() {
const video = [], audio = []
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)) {
video.push({ name: dev, devicePath: dev, type: 'device' })
}
} catch {}
const display = process.env.DISPLAY || ':0'
video.unshift({ name: `Рабочий стол (${display})`, type: 'desktop', devicePath: display })
const wins = await getLinuxWindowsList()
video.push(...wins.map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title })))
try {
const { stdout: paList } = await runCommand('pactl', ['list', 'short', 'sources'])
for (const line of paList.split('\n').filter(Boolean)) {
const parts = line.split('\t')
if (parts.length >= 2) {
const devName = parts[1]
audio.push({
name: devName,
deviceName: devName,
type: devName.includes('.monitor') ? 'system' : 'device',
description: devName.includes('.monitor') ? 'Системный звук (loopback)' : ''
})
}
}
} catch {
audio.push({ name: 'default', deviceName: 'default', type: 'device' })
}
return { video, audio }
}
function getLinuxWindowsList() {
return new Promise((resolve) => {
execFile('wmctrl', ['-l'], { timeout: 3000 }, (err, stdout) => {
if (err || !stdout) { resolve([]); return }
resolve(stdout.trim().split('\n')
.map(line => {
const parts = line.split(/\s+/)
return { title: parts.slice(3).join(' ').trim(), type: 'window' }
})
.filter(w => w.title))
})
})
}
// ─── Public API ───────────────────────────────────────────────────────────────
async function getDevices() {
let result
if (process.platform === 'win32') {
result = await getWindowsDevices()
} else if (process.platform === 'darwin') {
result = await getMacDevices()
} else {
result = await getLinuxDevices()
}
result.video.unshift({ name: '— Нет видео —', type: 'none', deviceName: 'none' })
result.audio.unshift({ name: '— Нет аудио —', type: 'none', deviceName: 'none' })
return result
}
async function getWindows() {
if (process.platform === 'win32') return getWindowsWindowsList()
if (process.platform === 'darwin') return getMacWindowsList()
return getLinuxWindowsList()
}
module.exports = { getDevices, getWindows }