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 -63
View File
@@ -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 (
<div className="app">
@@ -149,9 +174,7 @@ export default function App() {
<div className="titlebar-left">
<span className="app-logo"></span>
<span className="app-title">SRT Streamer</span>
{activeCount > 0 && (
<span className="active-badge">{activeCount} active</span>
)}
{activeCount > 0 && <span className="active-badge">{activeCount} active</span>}
</div>
<div className="titlebar-controls" style={{ WebkitAppRegion: 'no-drag' }}>
<button className="tb-btn" onClick={handleMinimize} title="Minimize">&#8722;</button>
@@ -161,26 +184,14 @@ export default function App() {
{/* Tab Bar */}
<div className="tabbar">
<button
className={`tab ${activeTab === 'streams' ? 'active' : ''}`}
onClick={() => setActiveTab('streams')}
>
Streams
{activeCount > 0 && <span className="tab-badge">{activeCount}</span>}
<button className={`tab ${activeTab === 'streams' ? 'active' : ''}`} onClick={() => setActiveTab('streams')}>
Streams {activeCount > 0 && <span className="tab-badge">{activeCount}</span>}
</button>
<button
className={`tab ${activeTab === 'tolbek' ? 'active' : ''}`}
onClick={() => setActiveTab('tolbek')}
>
Tolbek (Receiver)
{tolbek.running && <span className="tab-badge tab-badge--green"></span>}
<button className={`tab ${activeTab === 'tolbek' ? 'active' : ''}`} onClick={() => setActiveTab('tolbek')}>
Tolbek (Receiver) {tolbek.running && <span className="tab-badge tab-badge--green"></span>}
</button>
<button
className={`tab ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveTab('logs')}
>
Logs
{logs.some(l => l.isError) && <span className="tab-badge tab-badge--red">!</span>}
<button className={`tab ${activeTab === 'logs' ? 'active' : ''}`} onClick={() => setActiveTab('logs')}>
Logs {hasErrors && <span className="tab-badge tab-badge--red">!</span>}
</button>
<div className="tabbar-spacer" />
<button className="tab tab--icon" onClick={refreshDevices} title="Refresh device list">
@@ -188,6 +199,18 @@ export default function App() {
</button>
</div>
{/* Global recording bar — shown only on Streams tab */}
{activeTab === 'streams' && (
<RecordingBar
recording={recording}
onChange={setRecording}
recordingCount={recordingCount}
totalCount={streams.length}
onSelectAll={setAllRecord}
ipc={ipc}
/>
)}
{/* Content */}
<div className="content">
{activeTab === 'streams' && (
@@ -200,6 +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}
/>
))}
<button className="add-stream-btn" onClick={addStream}>
<span className="add-icon">+</span>
<span>Add Stream</span>
@@ -217,19 +240,11 @@ export default function App() {
)}
{activeTab === 'tolbek' && (
<TolbekSettings
config={tolbek}
onChange={setTolbek}
ipc={ipc}
/>
<TolbekSettings config={tolbek} onChange={setTolbek} ipc={ipc} />
)}
{activeTab === 'logs' && (
<LogPanel
logs={logs}
streams={streams}
onClear={() => setLogs([])}
/>
<LogPanel logs={logs} streams={streams} onClear={() => setLogs([])} />
)}
</div>
</div>
+87
View File
@@ -0,0 +1,87 @@
import React from 'react'
export default function RecordingBar({ recording, onChange, recordingCount, totalCount, onSelectAll, ipc }) {
const allSelected = totalCount > 0 && recordingCount === totalCount
const someSelected = recordingCount > 0 && recordingCount < totalCount
async function handlePickFolder() {
if (!ipc) return
const folder = await ipc.pickFolder()
if (folder) onChange({ ...recording, folder })
}
function handleOpenFolder() {
if (ipc && recording.folder) ipc.openFolder(recording.folder)
}
return (
<div className="recording-bar">
{/* Folder picker */}
<div className="rec-folder-section">
<span className="rec-label">🎬 Запись:</span>
<button
className="btn btn--sm btn--outline"
onClick={handlePickFolder}
title="Выбрать папку для записи"
>
📁 Папка
</button>
{recording.folder ? (
<span
className="rec-folder-path"
title={recording.folder}
onClick={handleOpenFolder}
>
{recording.folder}
</span>
) : (
<span className="rec-folder-empty">не выбрана</span>
)}
</div>
{/* Segment duration */}
<div className="rec-segment-section">
<label className="rec-label">Сегмент:</label>
<input
className="rec-segment-input"
type="number"
min="1"
max="1440"
value={recording.segmentMinutes}
onChange={e => onChange({ ...recording, segmentMinutes: Math.max(1, parseInt(e.target.value) || 60) })}
title="Длительность файла в минутах"
/>
<span className="rec-label-unit">мин</span>
</div>
{/* Select all / none */}
<div className="rec-select-section">
<label
className={`rec-selectall-label ${allSelected ? 'rec-selectall--on' : someSelected ? 'rec-selectall--partial' : ''}`}
title={allSelected ? 'Снять выбор со всех' : 'Записывать все потоки'}
>
<input
type="checkbox"
checked={allSelected}
ref={el => { if (el) el.indeterminate = someSelected }}
onChange={e => onSelectAll(e.target.checked)}
style={{ display: 'none' }}
/>
<span className="rec-selectall-box">
{allSelected ? '☑' : someSelected ? '⊟' : '☐'}
</span>
Все потоки
</label>
{recordingCount > 0 && (
<span className="rec-active-badge">
{recordingCount} / {totalCount}
</span>
)}
</div>
{!recording.folder && recordingCount > 0 && (
<span className="rec-warn"> Выберите папку</span>
)}
</div>
)
}
+12 -9
View File
@@ -2,20 +2,22 @@ import React, { useState } from 'react'
import StreamSettings from './StreamSettings.jsx'
const STATUS_LABELS = {
idle: 'Idle',
connecting: 'Connecting…',
running: 'Live',
error: 'Error'
idle: 'Idle',
connecting: 'Connecting…',
running: 'Live',
error: 'Error',
reconnecting: 'Reconnecting…'
}
const STATUS_CLASS = {
idle: 'status--idle',
connecting: 'status--connecting',
running: 'status--running',
error: 'status--error'
idle: 'status--idle',
connecting: 'status--connecting',
running: 'status--running',
error: 'status--error',
reconnecting: 'status--connecting'
}
export default function StreamCard({ stream, devices, windows, devicesLoaded, onChange, onStart, onStop, onRemove, onRefreshDevices }) {
export default function StreamCard({ stream, devices, windows, devicesLoaded, recordFolder, onChange, onStart, onStop, onRemove, onRefreshDevices }) {
const [expanded, setExpanded] = useState(true)
const [editingName, setEditingName] = useState(false)
const [nameValue, setNameValue] = useState(stream.name)
@@ -121,6 +123,7 @@ export default function StreamCard({ stream, devices, windows, devicesLoaded, on
disabled={isActive}
onChange={onChange}
onRefreshDevices={onRefreshDevices}
recordFolder={recordFolder}
/>
)}
</div>
+28 -7
View File
@@ -95,7 +95,7 @@ function detectProfile(res, fps, vbr) {
}
// ─── main component ───────────────────────────────────────────────────────────
export default function StreamSettings({ stream, devices, windows, devicesLoaded, disabled, onChange, onRefreshDevices }) {
export default function StreamSettings({ stream, devices, windows, devicesLoaded, disabled, onChange, onRefreshDevices, recordFolder }) {
const [activeSection, setActiveSection] = useState('source')
const updateVideo = changes => onChange({ videoSource: { ...stream.videoSource, ...changes } })
@@ -134,12 +134,13 @@ export default function StreamSettings({ stream, devices, windows, devicesLoaded
const dev = allOpts.find(d => d.deviceName === deviceName || d.name === deviceName)
if (dev) {
updateVideo({
deviceName: dev.deviceName || dev.name,
type: dev.type,
deviceIndex: dev.deviceIndex,
devicePath: dev.devicePath,
windowTitle: dev.windowTitle,
screenIndex: dev.monitorIndex,
deviceName: dev.deviceName || dev.name,
type: dev.type,
deviceIndex: dev.deviceIndex,
devicePath: dev.devicePath,
windowTitle: dev.windowTitle || dev.name,
screenIndex: dev.monitorIndex,
monitorBounds: dev.monitorBounds || null,
})
}
}
@@ -228,6 +229,26 @@ export default function StreamSettings({ stream, devices, windows, devicesLoaded
)}
</div>
{/* Recording checkbox */}
<div className="settings-group">
<div className="group-title">Запись</div>
<label className={`checkbox-label field rec-checkbox-label ${stream.record ? 'rec-checkbox-label--on' : ''}`}>
<input
type="checkbox"
checked={stream.record || false}
onChange={e => onChange({ record: e.target.checked })}
disabled={disabled}
/>
<span>Записывать этот поток</span>
{stream.record && !recordFolder && (
<span className="rec-warn-inline"> выберите папку в панели записи</span>
)}
{stream.record && recordFolder && (
<span className="rec-folder-hint"> {recordFolder}</span>
)}
</label>
</div>
{/* Audio */}
<div className="settings-group">
<div className="group-title">Аудиоисточник</div>
+160
View File
@@ -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;
}