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:
+78
-63
@@ -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">−</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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user