fix: window capture list not showing in video sources
Root cause: PowerShell command was wrapped in exec() with double-quote
escaping that silently failed. Rewrote using execFile() with args array
which requires no manual escaping.
Changes:
- electron/devices.js: use execFile('powershell.exe', [...args]) for
window enumeration — eliminates quoting issues entirely
- Window list now returned INSIDE getDevices() video array (type='window')
so they arrive together with cameras/screens in one IPC call
- StreamSettings: windows grouped from devices.video directly, with 🪟
emoji prefix for visual distinction
- Added inline "↻ Обновить" button in video source field header to
refresh devices+windows on demand without switching tabs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+152
-176
@@ -1,4 +1,5 @@
|
|||||||
const { spawn, exec } = require('child_process')
|
'use strict'
|
||||||
|
const { spawn, exec, execFile } = require('child_process')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
|
|
||||||
@@ -6,213 +7,237 @@ let ffmpegPath = null
|
|||||||
|
|
||||||
function getFfmpegPath() {
|
function getFfmpegPath() {
|
||||||
if (ffmpegPath) return ffmpegPath
|
if (ffmpegPath) return ffmpegPath
|
||||||
|
|
||||||
const resourcesPath = process.resourcesPath || path.join(__dirname, '..')
|
const resourcesPath = process.resourcesPath || path.join(__dirname, '..')
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(resourcesPath, 'ffmpeg-bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
|
path.join(resourcesPath, 'ffmpeg-bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
|
||||||
path.join(__dirname, '..', 'ffmpeg-bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
|
path.join(__dirname, '..', 'ffmpeg-bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const p of candidates) {
|
for (const p of candidates) {
|
||||||
if (fs.existsSync(p)) {
|
if (fs.existsSync(p)) { ffmpegPath = p; return p }
|
||||||
ffmpegPath = p
|
|
||||||
return p
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ffmpegPath = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
ffmpegPath = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||||
return ffmpegPath
|
return ffmpegPath
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCommand(cmd, args) {
|
function runCommand(cmd, args, timeoutMs = 10000) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||||
let stdout = ''
|
let stdout = '', stderr = ''
|
||||||
let stderr = ''
|
|
||||||
proc.stdout.on('data', d => stdout += d)
|
proc.stdout.on('data', d => stdout += d)
|
||||||
proc.stderr.on('data', d => stderr += d)
|
proc.stderr.on('data', d => stderr += d)
|
||||||
proc.on('close', () => resolve({ stdout, stderr }))
|
proc.on('close', () => resolve({ stdout, stderr }))
|
||||||
proc.on('error', () => resolve({ stdout: '', stderr: '' }))
|
proc.on('error', () => resolve({ stdout: '', stderr: '' }))
|
||||||
// Timeout
|
setTimeout(() => { try { proc.kill() } catch {} ; resolve({ stdout, stderr }) }, timeoutMs)
|
||||||
setTimeout(() => { proc.kill(); resolve({ stdout, stderr }) }, 10000)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Windows — enumerate application windows via PowerShell ───────────────────
|
||||||
|
// Uses execFile to pass arguments as array → no quoting/escaping issues
|
||||||
|
function getWindowsWindowsList() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// Each line: "WindowTitle|||ProcessName"
|
||||||
|
const ps1 = [
|
||||||
|
'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 },
|
||||||
|
(err, stdout) => {
|
||||||
|
if (err || !stdout) { resolve([]); return }
|
||||||
|
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 dShow devices + monitors ─────────────────────────────
|
||||||
async function getWindowsDevices() {
|
async function getWindowsDevices() {
|
||||||
const ffmpeg = getFfmpegPath()
|
const ffmpeg = getFfmpegPath()
|
||||||
const { stderr } = await runCommand(ffmpeg, [
|
const { stderr } = await runCommand(ffmpeg, ['-list_devices', 'true', '-f', 'dshow', '-i', 'dummy'])
|
||||||
'-list_devices', 'true',
|
|
||||||
'-f', 'dshow',
|
|
||||||
'-i', 'dummy'
|
|
||||||
])
|
|
||||||
|
|
||||||
const video = []
|
const video = []
|
||||||
const audio = []
|
const audio = []
|
||||||
|
|
||||||
const lines = stderr.split('\n')
|
for (const line of stderr.split('\n')) {
|
||||||
let section = null
|
const videoMatch = line.match(/"([^"]+)"\s*\(video\)/)
|
||||||
|
if (videoMatch) { video.push({ name: videoMatch[1], deviceName: videoMatch[1], type: 'device' }); continue }
|
||||||
for (const line of lines) {
|
const audioMatch = line.match(/"([^"]+)"\s*\(audio\)/)
|
||||||
if (line.includes('"') && line.includes('(video)')) {
|
if (audioMatch) { audio.push({ name: audioMatch[1], deviceName: audioMatch[1], type: 'device' }) }
|
||||||
section = 'video'
|
|
||||||
const match = line.match(/"([^"]+)"\s*\(video\)/)
|
|
||||||
if (match) {
|
|
||||||
video.push({ name: match[1], deviceName: match[1], type: 'device' })
|
|
||||||
}
|
|
||||||
} else if (line.includes('"') && line.includes('(audio)')) {
|
|
||||||
section = 'audio'
|
|
||||||
const match = line.match(/"([^"]+)"\s*\(audio\)/)
|
|
||||||
if (match) {
|
|
||||||
audio.push({ name: match[1], deviceName: match[1], type: 'device' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add system audio loopback devices (WASAPI)
|
// System audio loopback (works with VB-Audio, Stereo Mix, or virtual-audio-capturer)
|
||||||
// These appear as "virtual-audio-capturer" or similar dshow devices
|
|
||||||
// Also detect via registry / wmic
|
|
||||||
audio.push({
|
audio.push({
|
||||||
name: 'System Audio (Loopback)',
|
name: 'Системный звук (Loopback / Stereo Mix)',
|
||||||
deviceName: 'virtual-audio-capturer',
|
deviceName: 'virtual-audio-capturer',
|
||||||
type: 'system',
|
type: 'system',
|
||||||
description: 'Capture all system audio output'
|
description: 'Захват всего выходного звука'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add screen capture options
|
// Screen sources: full desktop
|
||||||
const screenDevices = [
|
const screenDevices = [
|
||||||
{ name: 'Desktop (Full Screen)', type: 'desktop', deviceName: 'desktop' },
|
{ name: 'Рабочий стол (весь экран)', type: 'desktop', deviceName: 'desktop' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// Try to enumerate monitors
|
// Per-monitor entries
|
||||||
const monitors = await getWindowsMonitors()
|
const monitors = await getWindowsMonitors()
|
||||||
monitors.forEach((m, i) => {
|
monitors.forEach((m, i) => {
|
||||||
screenDevices.push({
|
screenDevices.push({
|
||||||
name: `Monitor ${i + 1}${m.label ? ': ' + m.label : ''}`,
|
name: `Монитор ${i + 1}${m.label ? ': ' + m.label : ''}`,
|
||||||
type: 'desktop',
|
type: 'desktop',
|
||||||
deviceName: `desktop`,
|
deviceName: 'desktop',
|
||||||
monitorIndex: i
|
monitorIndex: i
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return { video: [...screenDevices, ...video], audio }
|
// Enumerate open windows — included directly in video sources
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getWindowsMonitors() {
|
async function getWindowsMonitors() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
exec('wmic desktopmonitor get Name,ScreenHeight,ScreenWidth /format:list', (err, stdout) => {
|
const t = setTimeout(() => resolve([]), 5000)
|
||||||
if (err) { resolve([]); return }
|
exec('wmic desktopmonitor get Name /format:list', (err, stdout) => {
|
||||||
|
clearTimeout(t)
|
||||||
|
if (err || !stdout) { resolve([]); return }
|
||||||
const monitors = []
|
const monitors = []
|
||||||
const blocks = stdout.split('\r\n\r\n').filter(b => b.trim())
|
for (const block of stdout.split(/\r?\n\r?\n/)) {
|
||||||
for (const block of blocks) {
|
const m = block.match(/Name=(.+)/)
|
||||||
const nameMatch = block.match(/Name=(.+)/)
|
if (m && m[1].trim()) monitors.push({ label: m[1].trim() })
|
||||||
if (nameMatch) monitors.push({ label: nameMatch[1].trim() })
|
|
||||||
}
|
}
|
||||||
resolve(monitors)
|
resolve(monitors)
|
||||||
})
|
})
|
||||||
setTimeout(() => resolve([]), 5000)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── macOS ────────────────────────────────────────────────────────────────────
|
||||||
async function getMacDevices() {
|
async function getMacDevices() {
|
||||||
const ffmpeg = getFfmpegPath()
|
const ffmpeg = getFfmpegPath()
|
||||||
const { stderr } = await runCommand(ffmpeg, [
|
const { stderr } = await runCommand(ffmpeg, ['-f', 'avfoundation', '-list_devices', 'true', '-i', ''])
|
||||||
'-f', 'avfoundation',
|
|
||||||
'-list_devices', 'true',
|
|
||||||
'-i', ''
|
|
||||||
])
|
|
||||||
|
|
||||||
const video = []
|
const video = [], audio = []
|
||||||
const audio = []
|
|
||||||
|
|
||||||
const lines = stderr.split('\n')
|
|
||||||
let section = null
|
let section = null
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of stderr.split('\n')) {
|
||||||
if (line.includes('AVFoundation video devices')) {
|
if (line.includes('AVFoundation video devices')) { section = 'video'; continue }
|
||||||
section = 'video'
|
if (line.includes('AVFoundation audio devices')) { section = 'audio'; continue }
|
||||||
continue
|
const m = line.match(/\[(\d+)\]\s+(.+)/)
|
||||||
}
|
if (!m) continue
|
||||||
if (line.includes('AVFoundation audio devices')) {
|
const [, index, name] = m
|
||||||
section = 'audio'
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = line.match(/\[(\d+)\]\s+(.+)/)
|
|
||||||
if (match) {
|
|
||||||
const index = match[1]
|
|
||||||
const name = match[2].trim()
|
|
||||||
if (section === 'video') {
|
if (section === 'video') {
|
||||||
const isScreen = name.toLowerCase().includes('screen') || name.toLowerCase().includes('capture')
|
const isScreen = /screen|capture|display/i.test(name)
|
||||||
video.push({
|
video.push({ name: name.trim(), deviceIndex: index, type: isScreen ? 'desktop' : 'device' })
|
||||||
name,
|
|
||||||
deviceIndex: index,
|
|
||||||
type: isScreen ? 'desktop' : 'device'
|
|
||||||
})
|
|
||||||
} else if (section === 'audio') {
|
} else if (section === 'audio') {
|
||||||
audio.push({
|
audio.push({ name: name.trim(), deviceIndex: index, type: 'device' })
|
||||||
name,
|
}
|
||||||
deviceIndex: index,
|
}
|
||||||
type: 'device'
|
|
||||||
|
// 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
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { video: [...video, ...windowDevices], 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 }
|
||||||
|
const wins = stdout.trim().split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
.map(title => ({ title, type: 'window' }))
|
||||||
|
resolve(wins)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { video, audio }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ─── Linux ────────────────────────────────────────────────────────────────────
|
||||||
async function getLinuxDevices() {
|
async function getLinuxDevices() {
|
||||||
const video = []
|
const video = [], audio = []
|
||||||
const audio = []
|
|
||||||
|
|
||||||
// V4L2 devices
|
// V4L2 cameras
|
||||||
try {
|
try {
|
||||||
const v4l2Devices = await new Promise((resolve) => {
|
const { stdout: devList } = await runCommand('ls', ['/dev/video0', '/dev/video1', '/dev/video2', '/dev/video3', '/dev/video4'])
|
||||||
exec('ls /dev/video* 2>/dev/null', (err, stdout) => {
|
for (const dev of devList.trim().split('\n').filter(Boolean)) {
|
||||||
if (err) { resolve([]); return }
|
video.push({ name: dev, devicePath: dev, type: 'device' })
|
||||||
resolve(stdout.trim().split('\n').filter(Boolean))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const dev of v4l2Devices) {
|
|
||||||
const { stdout } = await runCommand('v4l2-ctl', ['--device', dev, '--info'])
|
|
||||||
const nameMatch = stdout.match(/Card type\s*:\s*(.+)/)
|
|
||||||
video.push({
|
|
||||||
name: nameMatch ? nameMatch[1].trim() : dev,
|
|
||||||
devicePath: dev,
|
|
||||||
type: 'device'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch {}
|
||||||
|
|
||||||
// Add screen capture
|
|
||||||
const display = process.env.DISPLAY || ':0'
|
const display = process.env.DISPLAY || ':0'
|
||||||
video.unshift({ name: `Desktop (${display})`, type: 'desktop', devicePath: display })
|
video.unshift({ name: `Рабочий стол (${display})`, type: 'desktop', devicePath: display })
|
||||||
|
|
||||||
// PulseAudio devices
|
// 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)
|
||||||
|
|
||||||
|
// PulseAudio / ALSA
|
||||||
try {
|
try {
|
||||||
const { stdout: paList } = await runCommand('pactl', ['list', 'short', 'sources'])
|
const { stdout: paList } = await runCommand('pactl', ['list', 'short', 'sources'])
|
||||||
const lines = paList.split('\n').filter(Boolean)
|
for (const line of paList.split('\n').filter(Boolean)) {
|
||||||
for (const line of lines) {
|
|
||||||
const parts = line.split('\t')
|
const parts = line.split('\t')
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
const deviceName = parts[1]
|
const devName = parts[1]
|
||||||
const isMonitor = deviceName.includes('.monitor')
|
|
||||||
audio.push({
|
audio.push({
|
||||||
name: deviceName,
|
name: devName,
|
||||||
deviceName,
|
deviceName: devName,
|
||||||
type: isMonitor ? 'system' : 'device',
|
type: devName.includes('.monitor') ? 'system' : 'device',
|
||||||
description: isMonitor ? 'System audio (loopback)' : ''
|
description: devName.includes('.monitor') ? 'Системный звук (loopback)' : ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
audio.push({ name: 'default', deviceName: 'default', type: 'device' })
|
audio.push({ name: 'default', deviceName: 'default', type: 'device' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return { video, audio }
|
return { video, audio }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLinuxWindowsList() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile('wmctrl', ['-l'], { timeout: 3000 }, (err, stdout) => {
|
||||||
|
if (err || !stdout) { resolve([]); return }
|
||||||
|
const wins = stdout.trim().split('\n')
|
||||||
|
.map(line => {
|
||||||
|
const parts = line.split(/\s+/)
|
||||||
|
const title = parts.slice(3).join(' ').trim()
|
||||||
|
return { title, type: 'window' }
|
||||||
|
})
|
||||||
|
.filter(w => w.title)
|
||||||
|
resolve(wins)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||||
async function getDevices() {
|
async function getDevices() {
|
||||||
let result
|
let result
|
||||||
|
|
||||||
@@ -224,66 +249,17 @@ async function getDevices() {
|
|||||||
result = await getLinuxDevices()
|
result = await getLinuxDevices()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always add "None" options
|
result.video.unshift({ name: '— Нет видео —', type: 'none', deviceName: 'none' })
|
||||||
result.video.unshift({ name: '— No Video —', type: 'none', deviceName: 'none' })
|
result.audio.unshift({ name: '— Нет аудио —', type: 'none', deviceName: 'none' })
|
||||||
result.audio.unshift({ name: '— No Audio —', type: 'none', deviceName: 'none' })
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Still exposed separately for the refresh button
|
||||||
async function getWindows() {
|
async function getWindows() {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') return getWindowsWindowsList()
|
||||||
return getWindowsWindowsList()
|
if (process.platform === 'darwin') return getMacWindowsList()
|
||||||
} else if (process.platform === 'darwin') {
|
|
||||||
return getMacWindowsList()
|
|
||||||
} else {
|
|
||||||
return getLinuxWindowsList()
|
return getLinuxWindowsList()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function getWindowsWindowsList() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const script = `
|
|
||||||
$windows = Get-Process | Where-Object {$_.MainWindowTitle -ne ""} | Select-Object ProcessName, MainWindowTitle
|
|
||||||
$windows | ForEach-Object { Write-Output "$($_.MainWindowTitle)" }
|
|
||||||
`
|
|
||||||
exec(`powershell -Command "${script.replace(/\n/g, ' ')}"`, (err, stdout) => {
|
|
||||||
if (err) { resolve([]); return }
|
|
||||||
const windows = stdout.trim().split('\n')
|
|
||||||
.map(w => w.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map(title => ({ title, type: 'window' }))
|
|
||||||
resolve(windows)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMacWindowsList() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
exec(`osascript -e 'tell application "System Events" to get name of every process whose has UI elements is true'`, (err, stdout) => {
|
|
||||||
if (err) { resolve([]); return }
|
|
||||||
const apps = stdout.trim().split(', ')
|
|
||||||
.filter(Boolean)
|
|
||||||
.map(title => ({ title, type: 'window' }))
|
|
||||||
resolve(apps)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLinuxWindowsList() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
exec('wmctrl -l 2>/dev/null', (err, stdout) => {
|
|
||||||
if (err) { resolve([]); return }
|
|
||||||
const windows = stdout.trim().split('\n')
|
|
||||||
.map(line => {
|
|
||||||
const parts = line.split(/\s+/)
|
|
||||||
const title = parts.slice(3).join(' ')
|
|
||||||
return { title, type: 'window' }
|
|
||||||
})
|
|
||||||
.filter(w => w.title)
|
|
||||||
resolve(windows)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { getDevices, getWindows }
|
module.exports = { getDevices, getWindows }
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export default function App() {
|
|||||||
onStart={() => startStream(stream.id)}
|
onStart={() => startStream(stream.id)}
|
||||||
onStop={() => stopStream(stream.id)}
|
onStop={() => stopStream(stream.id)}
|
||||||
onRemove={() => removeStream(stream.id)}
|
onRemove={() => removeStream(stream.id)}
|
||||||
|
onRefreshDevices={refreshDevices}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const STATUS_CLASS = {
|
|||||||
error: 'status--error'
|
error: 'status--error'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StreamCard({ stream, devices, windows, devicesLoaded, onChange, onStart, onStop, onRemove }) {
|
export default function StreamCard({ stream, devices, windows, devicesLoaded, onChange, onStart, onStop, onRemove, onRefreshDevices }) {
|
||||||
const [expanded, setExpanded] = useState(true)
|
const [expanded, setExpanded] = useState(true)
|
||||||
const [editingName, setEditingName] = useState(false)
|
const [editingName, setEditingName] = useState(false)
|
||||||
const [nameValue, setNameValue] = useState(stream.name)
|
const [nameValue, setNameValue] = useState(stream.name)
|
||||||
@@ -120,6 +120,7 @@ export default function StreamCard({ stream, devices, windows, devicesLoaded, on
|
|||||||
devicesLoaded={devicesLoaded}
|
devicesLoaded={devicesLoaded}
|
||||||
disabled={isActive}
|
disabled={isActive}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onRefreshDevices={onRefreshDevices}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ function detectProfile(res, fps, vbr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── main component ───────────────────────────────────────────────────────────
|
// ─── main component ───────────────────────────────────────────────────────────
|
||||||
export default function StreamSettings({ stream, devices, windows, devicesLoaded, disabled, onChange }) {
|
export default function StreamSettings({ stream, devices, windows, devicesLoaded, disabled, onChange, onRefreshDevices }) {
|
||||||
const [activeSection, setActiveSection] = useState('source')
|
const [activeSection, setActiveSection] = useState('source')
|
||||||
|
|
||||||
const updateVideo = changes => onChange({ videoSource: { ...stream.videoSource, ...changes } })
|
const updateVideo = changes => onChange({ videoSource: { ...stream.videoSource, ...changes } })
|
||||||
@@ -104,13 +104,16 @@ export default function StreamSettings({ stream, devices, windows, devicesLoaded
|
|||||||
const videoDevices = devices.video || []
|
const videoDevices = devices.video || []
|
||||||
const audioDevices = devices.audio || []
|
const audioDevices = devices.audio || []
|
||||||
|
|
||||||
// Build grouped video options
|
// Build grouped video options — windows now come from devices.video directly
|
||||||
const noneOpts = videoDevices.filter(d => d.type === 'none')
|
const noneOpts = videoDevices.filter(d => d.type === 'none')
|
||||||
const screenOpts = videoDevices.filter(d => d.type === 'desktop')
|
const screenOpts = videoDevices.filter(d => d.type === 'desktop')
|
||||||
const cameraOpts = videoDevices.filter(d => d.type === 'device')
|
const cameraOpts = videoDevices.filter(d => d.type === 'device')
|
||||||
const windowOpts = (windows || []).map(w => ({
|
// Windows from devices.video (already enumerated server-side) + any extra from prop
|
||||||
name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title
|
const windowsFromDevices = videoDevices.filter(d => d.type === 'window')
|
||||||
}))
|
const windowsFromProp = (windows || [])
|
||||||
|
.filter(w => !windowsFromDevices.find(d => d.deviceName === w.title))
|
||||||
|
.map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title }))
|
||||||
|
const windowOpts = [...windowsFromDevices, ...windowsFromProp]
|
||||||
|
|
||||||
const videoOptions = [
|
const videoOptions = [
|
||||||
...noneOpts,
|
...noneOpts,
|
||||||
@@ -178,7 +181,17 @@ export default function StreamSettings({ stream, devices, windows, devicesLoaded
|
|||||||
<div className="settings-group">
|
<div className="settings-group">
|
||||||
<div className="group-title">Видеоисточник</div>
|
<div className="group-title">Видеоисточник</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
|
<div className="field-label-row">
|
||||||
<label className="field-label">Устройство / Источник</label>
|
<label className="field-label">Устройство / Источник</label>
|
||||||
|
{onRefreshDevices && (
|
||||||
|
<button
|
||||||
|
className="btn-inline-refresh"
|
||||||
|
onClick={onRefreshDevices}
|
||||||
|
disabled={disabled || !devicesLoaded}
|
||||||
|
title="Обновить список устройств и окон"
|
||||||
|
>↻ Обновить</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{devicesLoaded ? (
|
{devicesLoaded ? (
|
||||||
<select
|
<select
|
||||||
className="field-select"
|
className="field-select"
|
||||||
@@ -193,12 +206,12 @@ export default function StreamSettings({ stream, devices, windows, devicesLoaded
|
|||||||
{dev.name}
|
{dev.name}
|
||||||
</option>
|
</option>
|
||||||
: <option key={dev.deviceName || i} value={dev.deviceName || dev.name}>
|
: <option key={dev.deviceName || i} value={dev.deviceName || dev.name}>
|
||||||
{dev.name}
|
{dev.type === 'window' ? `🪟 ${dev.name}` : dev.name}
|
||||||
</option>
|
</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<div className="field-loading">Загрузка устройств…</div>
|
<div className="field-loading">Загрузка устройств и окон…</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -485,6 +485,32 @@ html, body, #root {
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.field-label-row .field-label { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.btn-inline-refresh {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-inline-refresh:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
.btn-inline-refresh:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
.fields-row {
|
.fields-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user