diff --git a/CHANGELOG.md b/CHANGELOG.md index e4851a6..4855798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.14.0] - 2026-03-09 + +### Added + +#### Advanced OBS Studio Integration + +- **AdvancedOBSControls Component** (`src/components/AdvancedOBSControls.tsx`): Main advanced controls component with tabbed interface for quick controls, scene collections, profiles, and statistics +- **SourceFilters Component** (`src/components/SourceFilters.tsx`): Source filter management with add, edit, and remove functionality for audio/video filters +- **ReplayBufferControls Component** (`src/components/ReplayBufferControls.tsx`): Dedicated replay buffer controls with start/stop/save functionality +- **SceneCollectionManager Component** (`src/components/SceneCollectionManager.tsx`): Scene collection management with switch and create capabilities + +#### Advanced OBS Features + +- **Replay Buffer**: Start/stop replay buffer, save replay with one click +- **Virtual Camera**: Toggle virtual camera output for video conferencing +- **Studio Mode**: Enable/disable studio mode for preview/edit workflow +- **Scene Collections**: View, switch between, and create new scene collections +- **Profile Management**: View and switch between OBS profiles +- **Source Filters**: Add, configure, and remove filters from audio/video sources +- **Statistics Display**: Real-time CPU usage, memory usage, frame timing, and more + +#### Service Layer Updates + +- **OBSWebSocketService**: Added methods for replay buffer control, virtual camera, studio mode, scene collections, profiles, and filter management +- **Media Input Actions**: Fixed media playback controls using TriggerMediaInputAction API +- **Scene Item Creation**: Fixed createSceneItem to use correct OBS WebSocket 5.x API + +#### Integration + +- Added "Advanced" tab to OBSIntegration component for centralized access to all advanced controls + +--- + ## [1.13.0] - 2026-03-09 ### Added diff --git a/package.json b/package.json index 024cc63..1a180fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "v-streaming", - "version": "1.13.0", + "version": "1.14.0", "private": true, "type": "module", "scripts": { diff --git a/src/components/AdvancedOBSControls.css b/src/components/AdvancedOBSControls.css new file mode 100644 index 0000000..6a6cca2 --- /dev/null +++ b/src/components/AdvancedOBSControls.css @@ -0,0 +1,431 @@ +/** + * Advanced OBS Controls Styles + */ + +.advanced-obs-controls { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + border-radius: 12px; + padding: 20px; + min-height: 400px; +} + +.aoc-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; +} + +.aoc-header h2 { + margin: 0; + color: #fff; + font-size: 1.5rem; + font-weight: 600; +} + +.aoc-subtitle { + margin: 4px 0 0; + color: #888; + font-size: 0.9rem; +} + +.aoc-header-actions { + display: flex; + gap: 10px; +} + +.aoc-refresh-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + transition: all 0.2s ease; +} + +.aoc-refresh-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.2); +} + +.aoc-refresh-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.aoc-close-btn { + background: rgba(255, 255, 255, 0.1); + border: none; + color: #888; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + font-size: 1.1rem; + transition: all 0.2s ease; +} + +.aoc-close-btn:hover { + background: rgba(255, 255, 255, 0.2); + color: #fff; +} + +.aoc-tabs { + display: flex; + gap: 4px; + margin-bottom: 20px; + background: rgba(0, 0, 0, 0.3); + padding: 4px; + border-radius: 8px; +} + +.aoc-tab { + flex: 1; + background: transparent; + border: none; + color: #888; + padding: 10px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s ease; +} + +.aoc-tab:hover { + color: #fff; +} + +.aoc-tab.active { + background: #6c5ce7; + color: #fff; +} + +.aoc-loading-bar { + height: 3px; + background: linear-gradient(90deg, #6c5ce7, #a29bfe, #6c5ce7); + background-size: 200% 100%; + animation: loading 1.5s infinite; + border-radius: 2px; + margin-bottom: 20px; +} + +@keyframes loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.aoc-content { + min-height: 300px; +} + +/* Quick Controls */ +.aoc-quick-controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} + +.aoc-card { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + overflow: hidden; +} + +.aoc-card-header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.aoc-card-icon { + font-size: 1.3rem; +} + +.aoc-card-header h3 { + margin: 0; + color: #fff; + font-size: 1rem; + font-weight: 500; +} + +.aoc-card-content { + padding: 16px; +} + +.aoc-status-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.aoc-status-badge { + padding: 4px 12px; + border-radius: 20px; + font-size: 0.8rem; + font-weight: 500; +} + +.aoc-status-badge.active { + background: rgba(46, 204, 113, 0.2); + color: #2ecc71; +} + +.aoc-status-badge.inactive { + background: rgba(255, 255, 255, 0.1); + color: #888; +} + +.aoc-duration { + color: #888; + font-size: 0.9rem; +} + +.aoc-button-row { + display: flex; + gap: 10px; +} + +.aoc-btn { + padding: 10px 20px; + border-radius: 6px; + border: none; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.aoc-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.aoc-btn.primary { + background: #6c5ce7; + color: #fff; +} + +.aoc-btn.primary:hover:not(:disabled) { + background: #5b4cdb; +} + +.aoc-btn.danger { + background: #e74c3c; + color: #fff; +} + +.aoc-btn.danger:hover:not(:disabled) { + background: #c0392b; +} + +.aoc-btn.success { + background: #27ae60; + color: #fff; +} + +.aoc-btn.success:hover:not(:disabled) { + background: #219a52; +} + +.aoc-btn.small { + padding: 6px 14px; + font-size: 0.8rem; +} + +.aoc-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.aoc-toggle-label { + color: #888; + font-size: 0.9rem; +} + +/* Toggle Switch */ +.aoc-switch { + position: relative; + display: inline-block; + width: 50px; + height: 26px; +} + +.aoc-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.aoc-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.1); + transition: 0.3s; + border-radius: 26px; +} + +.aoc-slider:before { + position: absolute; + content: ""; + height: 20px; + width: 20px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.3s; + border-radius: 50%; +} + +.aoc-switch input:checked + .aoc-slider { + background-color: #6c5ce7; +} + +.aoc-switch input:checked + .aoc-slider:before { + transform: translateX(24px); +} + +/* List View */ +.aoc-list-view { + background: rgba(255, 255, 255, 0.02); + border-radius: 10px; + overflow: hidden; +} + +.aoc-list-header { + padding: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.aoc-list-header h3 { + margin: 0; + color: #fff; + font-size: 1rem; + font-weight: 500; +} + +.aoc-list { + max-height: 400px; + overflow-y: auto; +} + +.aoc-list-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + transition: background 0.2s ease; +} + +.aoc-list-item:hover { + background: rgba(255, 255, 255, 0.03); +} + +.aoc-list-item.current { + background: rgba(108, 92, 231, 0.1); +} + +.aoc-list-item-content { + display: flex; + align-items: center; + gap: 12px; +} + +.aoc-list-item-icon { + font-size: 1.1rem; +} + +.aoc-list-item-name { + color: #fff; + font-size: 0.95rem; +} + +.aoc-current-badge { + background: rgba(46, 204, 113, 0.2); + color: #2ecc71; + padding: 2px 8px; + border-radius: 10px; + font-size: 0.75rem; + font-weight: 500; +} + +.aoc-active-indicator { + color: #2ecc71; + font-size: 1.2rem; +} + +/* Stats Grid */ +.aoc-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; +} + +.aoc-stat-card { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 16px; + text-align: center; +} + +.aoc-stat-label { + display: block; + color: #888; + font-size: 0.8rem; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.aoc-stat-value { + display: block; + color: #fff; + font-size: 1.5rem; + font-weight: 600; +} + +/* Warning */ +.aoc-warning { + display: flex; + align-items: center; + gap: 10px; + background: rgba(243, 156, 18, 0.1); + border: 1px solid rgba(243, 156, 18, 0.3); + border-radius: 8px; + padding: 16px; + color: #f39c12; +} + +.aoc-warning-icon { + font-size: 1.5rem; +} + +/* Scrollbar */ +.aoc-list::-webkit-scrollbar { + width: 6px; +} + +.aoc-list::-webkit-scrollbar-track { + background: transparent; +} + +.aoc-list::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; +} + +.aoc-list::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); +} \ No newline at end of file diff --git a/src/components/AdvancedOBSControls.tsx b/src/components/AdvancedOBSControls.tsx new file mode 100644 index 0000000..3399cd2 --- /dev/null +++ b/src/components/AdvancedOBSControls.tsx @@ -0,0 +1,402 @@ +/** + * V-Streaming Advanced OBS Controls Component + * Advanced controls for OBS including replay buffer, virtual camera, studio mode, and stats + */ + +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useOBSWebSocket } from '../hooks/useOBSWebSocket'; +import './AdvancedOBSControls.css'; + +interface AdvancedOBSControlsProps { + onClose?: () => void; +} + +type TabType = 'quick' | 'collections' | 'profiles' | 'stats'; + +export const AdvancedOBSControls: React.FC = ({ onClose }) => { + const { t } = useTranslation(); + const { + isConnected, + stats, + replayBufferStatus, + virtualCameraStatus, + studioModeStatus, + sceneCollections, + profiles, + currentSceneCollection, + currentProfile, + getStats, + startReplayBuffer, + stopReplayBuffer, + saveReplayBuffer, + startVirtualCamera, + stopVirtualCamera, + setStudioModeEnabled, + getSceneCollections, + getProfiles, + setCurrentSceneCollection, + setCurrentProfile, + } = useOBSWebSocket(); + + const [activeTab, setActiveTab] = useState('quick'); + const [loading, setLoading] = useState(false); + const [replaySaving, setReplaySaving] = useState(false); + + useEffect(() => { + if (isConnected) { + refreshData(); + } + }, [isConnected]); + + const refreshData = async () => { + try { + await Promise.all([ + getStats(), + getSceneCollections(), + getProfiles(), + ]); + } catch (error) { + console.error('Failed to refresh data:', error); + } + }; + + const handleReplayBufferToggle = async () => { + setLoading(true); + try { + if (replayBufferStatus?.outputActive) { + await stopReplayBuffer(); + } else { + await startReplayBuffer(); + } + } catch (error) { + console.error('Failed to toggle replay buffer:', error); + } + setLoading(false); + }; + + const handleSaveReplay = async () => { + setReplaySaving(true); + try { + await saveReplayBuffer(); + } catch (error) { + console.error('Failed to save replay:', error); + } + setTimeout(() => setReplaySaving(false), 1000); + }; + + const handleVirtualCameraToggle = async () => { + setLoading(true); + try { + if (virtualCameraStatus?.outputActive) { + await stopVirtualCamera(); + } else { + await startVirtualCamera(); + } + } catch (error) { + console.error('Failed to toggle virtual camera:', error); + } + setLoading(false); + }; + + const handleStudioModeToggle = async () => { + setLoading(true); + try { + await setStudioModeEnabled(!studioModeStatus?.studioModeEnabled); + } catch (error) { + console.error('Failed to toggle studio mode:', error); + } + setLoading(false); + }; + + const handleCollectionChange = async (collectionName: string) => { + setLoading(true); + try { + await setCurrentSceneCollection(collectionName); + } catch (error) { + console.error('Failed to change scene collection:', error); + } + setLoading(false); + }; + + const handleProfileChange = async (profileName: string) => { + setLoading(true); + try { + await setCurrentProfile(profileName); + } catch (error) { + console.error('Failed to change profile:', error); + } + setLoading(false); + }; + + const formatDuration = (ms: number): string => { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; + }; + + if (!isConnected) { + return ( +
+
+ âš ī¸ + {t('obs.connectRequired', 'Connect to OBS to access advanced controls')} +
+
+ ); + } + + return ( +
+
+
+

{t('aoc.title', 'Advanced OBS Controls')}

+

{t('aoc.subtitle', 'Manage replay buffer, virtual camera, and more')}

+
+
+ + {onClose && ( + + )} +
+
+ +
+ + + + +
+ + {loading &&
} + +
+ {/* Quick Controls Tab */} + {activeTab === 'quick' && ( +
+ {/* Replay Buffer */} +
+
+ đŸ“ŧ +

{t('aoc.replayBuffer', 'Replay Buffer')}

+
+
+
+ + {replayBufferStatus?.outputActive ? t('common.active', 'Active') : t('common.inactive', 'Inactive')} + + {replayBufferStatus?.outputActive && ( + {formatDuration(replayBufferStatus.outputDuration)} + )} +
+
+ + {replayBufferStatus?.outputActive && ( + + )} +
+
+
+ + {/* Virtual Camera */} +
+
+ 📷 +

{t('aoc.virtualCamera', 'Virtual Camera')}

+
+
+
+ + {virtualCameraStatus?.outputActive ? t('aoc.running', 'Running') : t('aoc.stopped', 'Stopped')} + +
+
+ +
+
+
+ + {/* Studio Mode */} +
+
+ đŸŽŦ +

{t('aoc.studioMode', 'Studio Mode')}

+
+
+
+ + {studioModeStatus?.studioModeEnabled ? t('aoc.enabled', 'Enabled') : t('aoc.disabled', 'Disabled')} + + +
+
+
+
+ )} + + {/* Scene Collections Tab */} + {activeTab === 'collections' && ( +
+
+

{t('aoc.sceneCollections', 'Scene Collections')}

+
+
+ {sceneCollections.map((collection: any) => { + const name = collection.collectionName || collection; + const isCurrent = name === currentSceneCollection || collection.current; + return ( +
+
+ 📁 + {name} + {isCurrent && {t('aoc.current', 'Current')}} +
+
+ {isCurrent ? ( + ✓ + ) : ( + + )} +
+
+ ); + })} +
+
+ )} + + {/* Profiles Tab */} + {activeTab === 'profiles' && ( +
+
+

{t('aoc.profiles', 'Profiles')}

+
+
+ {profiles.map((profile: any) => { + const name = profile.profileName || profile; + const isCurrent = name === currentProfile || profile.current; + return ( +
+
+ âš™ī¸ + {name} + {isCurrent && {t('aoc.current', 'Current')}} +
+
+ {isCurrent ? ( + ✓ + ) : ( + + )} +
+
+ ); + })} +
+
+ )} + + {/* Statistics Tab */} + {activeTab === 'stats' && stats && ( +
+
+ {t('aoc.fps', 'FPS')} + {stats.activeFps?.toFixed(1) || 0} +
+
+ {t('aoc.cpu', 'CPU Usage')} + {stats.cpuUsage?.toFixed(1) || 0}% +
+
+ {t('aoc.memory', 'Memory')} + {(stats.memoryUsage || 0).toFixed(0)} MB +
+
+ {t('aoc.freeDisk', 'Free Disk')} + {(stats.freeDiskSpace || 0).toFixed(0)} MB +
+
+ {t('aoc.renderMissed', 'Render Missed')} + {stats.renderMissedFrames || 0} +
+
+ {t('aoc.outputSkipped', 'Output Skipped')} + {stats.outputSkippedFrames || 0} +
+
+ {t('aoc.avgFrameTime', 'Avg Frame Time')} + {(stats.averageFrameTime || 0).toFixed(2)} ms +
+
+ {t('aoc.avgRenderTime', 'Avg Render Time')} + {(stats.averageFrameRenderTime || 0).toFixed(2)} ms +
+
+ )} +
+
+ ); +}; + +export default AdvancedOBSControls; \ No newline at end of file diff --git a/src/components/OBSIntegration.tsx b/src/components/OBSIntegration.tsx index 392bcd8..a8cb00b 100644 --- a/src/components/OBSIntegration.tsx +++ b/src/components/OBSIntegration.tsx @@ -1,6 +1,10 @@ import React, { useState, useEffect } from 'react'; import { useOBSWebSocket } from '../hooks/useOBSWebSocket'; import { OBSConnectionConfig, OBSConnectionStatus } from '../types/obsWebSocket'; +import { AdvancedOBSControls } from './AdvancedOBSControls'; +import { SourceFilters } from './SourceFilters'; +import { ReplayBufferControls } from './ReplayBufferControls'; +import { SceneCollectionManager } from './SceneCollectionManager'; import './OBSIntegration.css'; // ============================================================================ @@ -13,7 +17,7 @@ interface OBSIntegrationProps { export const OBSIntegration: React.FC = ({ onClose }) => { const obs = useOBSWebSocket(); - const [activeTab, setActiveTab] = useState<'connection' | 'scenes' | 'stream' | 'recording' | 'inputs' | 'transitions'>('connection'); + const [activeTab, setActiveTab] = useState<'connection' | 'scenes' | 'stream' | 'recording' | 'inputs' | 'transitions' | 'advanced'>('connection'); const [connectionConfig, setConnectionConfig] = useState({ address: '127.0.0.1', port: 4455, @@ -447,6 +451,12 @@ export const OBSIntegration: React.FC = ({ onClose }) => { > 🔄 Transitions +
@@ -472,6 +482,10 @@ export const OBSIntegration: React.FC = ({ onClose }) => {
{renderTransitionsTab()}
+ +
+ +
); }; diff --git a/src/components/ReplayBufferControls.css b/src/components/ReplayBufferControls.css new file mode 100644 index 0000000..73a3b7d --- /dev/null +++ b/src/components/ReplayBufferControls.css @@ -0,0 +1,251 @@ +/** + * Replay Buffer Controls Styles + */ + +.replay-buffer-controls { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + border-radius: 12px; + padding: 20px; + min-height: 400px; +} + +.rbc-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; +} + +.rbc-header h2 { + margin: 0; + color: #fff; + font-size: 1.5rem; + font-weight: 600; +} + +.rbc-subtitle { + margin: 4px 0 0; + color: #888; + font-size: 0.9rem; +} + +.rbc-close-btn { + background: rgba(255, 255, 255, 0.1); + border: none; + color: #888; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + font-size: 1.1rem; + transition: all 0.2s ease; +} + +.rbc-close-btn:hover { + background: rgba(255, 255, 255, 0.2); + color: #fff; +} + +.rbc-loading-bar { + height: 3px; + background: linear-gradient(90deg, #6c5ce7, #a29bfe, #6c5ce7); + background-size: 200% 100%; + animation: loading 1.5s infinite; + border-radius: 2px; + margin-bottom: 20px; +} + +@keyframes loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.rbc-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.rbc-stat-card { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 16px; + text-align: center; +} + +.rbc-stat-label { + display: block; + color: #888; + font-size: 0.8rem; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.rbc-stat-value { + color: #fff; + font-size: 1.5rem; + font-weight: 600; +} + +.rbc-stat-value.large { + font-size: 2rem; +} + +.rbc-status-content { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.rbc-status-dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: #555; + transition: all 0.3s ease; +} + +.rbc-status-dot.active { + background: #e74c3c; + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.rbc-status-text { + color: #888; + font-size: 1rem; +} + +.rbc-status-text.active { + color: #e74c3c; +} + +.rbc-controls { + display: flex; + justify-content: center; + margin-bottom: 24px; +} + +.rbc-active-controls { + display: flex; + gap: 16px; +} + +.rbc-btn { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + border-radius: 8px; + border: none; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.rbc-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.rbc-btn.primary { + background: #6c5ce7; +} + +.rbc-btn.primary:hover:not(:disabled) { + background: #5b4cdb; +} + +.rbc-btn.danger { + background: #e74c3c; +} + +.rbc-btn.danger:hover:not(:disabled) { + background: #c0392b; +} + +.rbc-btn.success { + background: #27ae60; +} + +.rbc-btn.success:hover:not(:disabled) { + background: #219a52; +} + +.rbc-btn.large { + padding: 16px 32px; + font-size: 1.1rem; +} + +.rbc-btn-icon { + font-size: 1.2rem; +} + +.rbc-last-save { + display: flex; + align-items: center; + gap: 10px; + background: rgba(46, 204, 113, 0.1); + border: 1px solid rgba(46, 204, 113, 0.3); + border-radius: 8px; + padding: 12px 16px; + color: #2ecc71; + margin-bottom: 24px; +} + +.rbc-last-save-icon { + font-size: 1.2rem; +} + +.rbc-tips { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 10px; + padding: 16px; +} + +.rbc-tips h4 { + margin: 0 0 12px; + color: #fff; + font-size: 0.95rem; + font-weight: 500; +} + +.rbc-tips ul { + margin: 0; + padding-left: 20px; + color: #888; + font-size: 0.85rem; + line-height: 1.6; +} + +.rbc-tips li { + margin-bottom: 4px; +} + +/* Warning */ +.rbc-warning { + display: flex; + align-items: center; + gap: 10px; + background: rgba(243, 156, 18, 0.1); + border: 1px solid rgba(243, 156, 18, 0.3); + border-radius: 8px; + padding: 16px; + color: #f39c12; +} + +.rbc-warning-icon { + font-size: 1.5rem; +} \ No newline at end of file diff --git a/src/components/ReplayBufferControls.tsx b/src/components/ReplayBufferControls.tsx new file mode 100644 index 0000000..95ffc76 --- /dev/null +++ b/src/components/ReplayBufferControls.tsx @@ -0,0 +1,165 @@ +/** + * V-Streaming Replay Buffer Controls Component + * Dedicated controls for OBS replay buffer functionality + */ + +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useOBSWebSocket } from '../hooks/useOBSWebSocket'; +import './ReplayBufferControls.css'; + +interface ReplayBufferControlsProps { + onClose?: () => void; +} + +export const ReplayBufferControls: React.FC = ({ onClose }) => { + const { t } = useTranslation(); + const { + isConnected, + replayBufferStatus, + startReplayBuffer, + stopReplayBuffer, + saveReplayBuffer, + } = useOBSWebSocket(); + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [lastSaveTime, setLastSaveTime] = useState(null); + const [saveCount, setSaveCount] = useState(0); + + const handleStart = async () => { + setLoading(true); + try { + await startReplayBuffer(); + } catch (error) { + console.error('Failed to start replay buffer:', error); + } + setLoading(false); + }; + + const handleStop = async () => { + setLoading(true); + try { + await stopReplayBuffer(); + } catch (error) { + console.error('Failed to stop replay buffer:', error); + } + setLoading(false); + }; + + const handleSave = async () => { + setSaving(true); + try { + await saveReplayBuffer(); + setLastSaveTime(new Date()); + setSaveCount((prev) => prev + 1); + } catch (error) { + console.error('Failed to save replay buffer:', error); + } + setTimeout(() => setSaving(false), 1000); + }; + + if (!isConnected) { + return ( +
+
+ âš ī¸ + {t('obs.connectRequired', 'Connect to OBS to control replay buffer')} +
+
+ ); + } + + const isActive = replayBufferStatus?.outputActive; + const duration = replayBufferStatus?.outputDuration || 0; + const durationSeconds = Math.floor(duration / 1000); + const durationFormatted = `${Math.floor(durationSeconds / 60)}:${(durationSeconds % 60).toString().padStart(2, '0')}`; + + return ( +
+
+
+

{t('rbc.title', 'Replay Buffer')}

+

{t('rbc.subtitle', 'Capture and save recent gameplay moments')}

+
+ {onClose && ( + + )} +
+ + {loading &&
} + +
+
+ {t('rbc.status', 'Status')} +
+ + + {isActive ? t('rbc.recording', 'Recording') : t('rbc.stopped', 'Stopped')} + +
+
+ +
+ {t('rbc.bufferDuration', 'Buffer Duration')} + {durationFormatted} +
+ +
+ {t('rbc.savedReplays', 'Saved Replays')} + {saveCount} +
+
+ +
+ {!isActive ? ( + + ) : ( +
+ + +
+ )} +
+ + {lastSaveTime && ( +
+ ✅ + {t('rbc.lastSaved', 'Last replay saved at')} {lastSaveTime.toLocaleTimeString()} +
+ )} + +
+

{t('rbc.tips', 'Tips')}

+
    +
  • {t('rbc.tip1', 'Replay buffer continuously records your gameplay in memory')}
  • +
  • {t('rbc.tip2', 'Press "Save Replay" to keep the last few moments')}
  • +
  • {t('rbc.tip3', 'Configure duration and format in OBS settings')}
  • +
+
+
+ ); +}; + +export default ReplayBufferControls; \ No newline at end of file diff --git a/src/components/SceneCollectionManager.css b/src/components/SceneCollectionManager.css new file mode 100644 index 0000000..475c19a --- /dev/null +++ b/src/components/SceneCollectionManager.css @@ -0,0 +1,332 @@ +/** + * Scene Collection Manager Styles + */ + +.scene-collection-manager { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + border-radius: 12px; + padding: 20px; + min-height: 400px; +} + +.scm-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; +} + +.scm-header h2 { + margin: 0; + color: #fff; + font-size: 1.5rem; + font-weight: 600; +} + +.scm-subtitle { + margin: 4px 0 0; + color: #888; + font-size: 0.9rem; +} + +.scm-header-actions { + display: flex; + gap: 10px; +} + +.scm-refresh-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + transition: all 0.2s ease; +} + +.scm-refresh-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.2); +} + +.scm-refresh-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.scm-create-btn { + background: #6c5ce7; + border: none; + color: #fff; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + transition: all 0.2s ease; +} + +.scm-create-btn:hover:not(:disabled) { + background: #5b4cdb; +} + +.scm-create-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.scm-close-btn { + background: rgba(255, 255, 255, 0.1); + border: none; + color: #888; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + font-size: 1.1rem; + transition: all 0.2s ease; +} + +.scm-close-btn:hover { + background: rgba(255, 255, 255, 0.2); + color: #fff; +} + +.scm-loading-bar { + height: 3px; + background: linear-gradient(90deg, #6c5ce7, #a29bfe, #6c5ce7); + background-size: 200% 100%; + animation: loading 1.5s infinite; + border-radius: 2px; + margin-bottom: 20px; +} + +@keyframes loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.scm-collections { + display: flex; + flex-direction: column; + gap: 8px; +} + +.scm-collection-item { + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 16px; + transition: all 0.2s ease; +} + +.scm-collection-item:hover { + background: rgba(255, 255, 255, 0.08); +} + +.scm-collection-item.current { + background: rgba(108, 92, 231, 0.15); + border-color: rgba(108, 92, 231, 0.3); +} + +.scm-collection-info { + display: flex; + align-items: center; + gap: 12px; +} + +.scm-collection-icon { + font-size: 1.5rem; +} + +.scm-collection-details { + display: flex; + align-items: center; + gap: 10px; +} + +.scm-collection-name { + color: #fff; + font-size: 1rem; + font-weight: 500; +} + +.scm-current-badge { + background: rgba(46, 204, 113, 0.2); + color: #2ecc71; + padding: 2px 8px; + border-radius: 10px; + font-size: 0.75rem; + font-weight: 500; +} + +.scm-active-indicator { + color: #2ecc71; + font-size: 1.3rem; +} + +.scm-collection-actions { + display: flex; + gap: 8px; +} + +.scm-btn { + padding: 8px 16px; + border-radius: 6px; + border: none; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.scm-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.scm-btn.primary { + background: #6c5ce7; +} + +.scm-btn.primary:hover:not(:disabled) { + background: #5b4cdb; +} + +/* Dialog */ +.scm-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.scm-dialog { + background: #1a1a2e; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + width: 90%; + max-width: 400px; + overflow: hidden; +} + +.scm-dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.scm-dialog-header h3 { + margin: 0; + color: #fff; + font-size: 1.1rem; + font-weight: 500; +} + +.scm-dialog-close { + background: transparent; + border: none; + color: #888; + width: 28px; + height: 28px; + border-radius: 50%; + cursor: pointer; + font-size: 1rem; + transition: all 0.2s ease; +} + +.scm-dialog-close:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.scm-dialog-content { + padding: 20px; +} + +.scm-field { + margin-bottom: 16px; +} + +.scm-field label { + display: block; + color: #888; + font-size: 0.85rem; + margin-bottom: 8px; +} + +.scm-field input { + width: 100%; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #fff; + padding: 12px; + border-radius: 8px; + font-size: 0.95rem; + transition: all 0.2s ease; +} + +.scm-field input:focus { + outline: none; + border-color: #6c5ce7; +} + +.scm-field input::placeholder { + color: #555; +} + +.scm-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 16px 20px; + background: rgba(0, 0, 0, 0.2); + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + +/* Empty State */ +.scm-empty { + text-align: center; + padding: 60px 20px; + color: #888; +} + +.scm-empty-icon { + font-size: 3rem; + margin-bottom: 16px; + display: block; +} + +.scm-empty p { + margin: 0; + font-size: 1rem; +} + +/* Warning */ +.scm-warning { + display: flex; + align-items: center; + gap: 10px; + background: rgba(243, 156, 18, 0.1); + border: 1px solid rgba(243, 156, 18, 0.3); + border-radius: 8px; + padding: 16px; + color: #f39c12; +} + +.scm-warning-icon { + font-size: 1.5rem; +} \ No newline at end of file diff --git a/src/components/SceneCollectionManager.tsx b/src/components/SceneCollectionManager.tsx new file mode 100644 index 0000000..1053fdb --- /dev/null +++ b/src/components/SceneCollectionManager.tsx @@ -0,0 +1,189 @@ +/** + * V-Streaming Scene Collection Manager Component + * Manage OBS scene collections + */ + +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useOBSWebSocket } from '../hooks/useOBSWebSocket'; +import './SceneCollectionManager.css'; + +interface SceneCollectionManagerProps { + onClose?: () => void; +} + +export const SceneCollectionManager: React.FC = ({ onClose }) => { + const { t } = useTranslation(); + const { + isConnected, + sceneCollections, + currentSceneCollection, + getSceneCollections, + setCurrentSceneCollection, + createSceneCollection, + } = useOBSWebSocket(); + + const [loading, setLoading] = useState(false); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [newCollectionName, setNewCollectionName] = useState(''); + + useEffect(() => { + if (isConnected) { + loadCollections(); + } + }, [isConnected]); + + const loadCollections = async () => { + setLoading(true); + try { + await getSceneCollections(); + } catch (error) { + console.error('Failed to load scene collections:', error); + } + setLoading(false); + }; + + const handleSwitchCollection = async (collectionName: string) => { + if (collectionName === currentSceneCollection) return; + setLoading(true); + try { + await setCurrentSceneCollection(collectionName); + } catch (error) { + console.error('Failed to switch collection:', error); + } + setLoading(false); + }; + + const handleCreateCollection = async () => { + if (!newCollectionName.trim()) return; + setLoading(true); + try { + await createSceneCollection(newCollectionName.trim()); + setShowCreateDialog(false); + setNewCollectionName(''); + await loadCollections(); + } catch (error) { + console.error('Failed to create collection:', error); + } + setLoading(false); + }; + + if (!isConnected) { + return ( +
+
+ âš ī¸ + {t('obs.connectRequired', 'Connect to OBS to manage scene collections')} +
+
+ ); + } + + return ( +
+
+
+

{t('scm.title', 'Scene Collections')}

+

{t('scm.subtitle', 'Switch between different scene setups')}

+
+
+ + + {onClose && ( + + )} +
+
+ + {loading &&
} + +
+ {sceneCollections.length === 0 ? ( +
+ 📁 +

{t('scm.noCollections', 'No scene collections found')}

+
+ ) : ( + sceneCollections.map((collection: any) => { + const name = collection.collectionName || collection; + const isCurrent = name === currentSceneCollection || collection.current; + return ( +
+
+ 📁 +
+ {name} + {isCurrent && ( + {t('scm.active', 'Active')} + )} +
+
+
+ {isCurrent ? ( + ✓ + ) : ( + + )} +
+
+ ); + }) + )} +
+ + {/* Create Collection Dialog */} + {showCreateDialog && ( +
+
+
+

{t('scm.createCollection', 'Create New Collection')}

+ +
+
+
+ + setNewCollectionName(e.target.value)} + placeholder={t('scm.namePlaceholder', 'Enter collection name')} + /> +
+
+
+ + +
+
+
+ )} +
+ ); +}; + +export default SceneCollectionManager; \ No newline at end of file diff --git a/src/components/SourceFilters.css b/src/components/SourceFilters.css new file mode 100644 index 0000000..5993d9f --- /dev/null +++ b/src/components/SourceFilters.css @@ -0,0 +1,401 @@ +/** + * Source Filters Styles + */ + +.source-filters { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + border-radius: 12px; + padding: 20px; + min-height: 400px; +} + +.sf-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; +} + +.sf-header h2 { + margin: 0; + color: #fff; + font-size: 1.5rem; + font-weight: 600; +} + +.sf-subtitle { + margin: 4px 0 0; + color: #888; + font-size: 0.9rem; +} + +.sf-header-actions { + display: flex; + gap: 10px; +} + +.sf-refresh-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + transition: all 0.2s ease; +} + +.sf-refresh-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.2); +} + +.sf-refresh-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.sf-close-btn { + background: rgba(255, 255, 255, 0.1); + border: none; + color: #888; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + font-size: 1.1rem; + transition: all 0.2s ease; +} + +.sf-close-btn:hover { + background: rgba(255, 255, 255, 0.2); + color: #fff; +} + +.sf-source-selector { + margin-bottom: 20px; +} + +.sf-source-selector label { + display: block; + color: #888; + font-size: 0.85rem; + margin-bottom: 8px; +} + +.sf-source-selector select { + width: 100%; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #fff; + padding: 12px; + border-radius: 8px; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.sf-source-selector select:hover { + border-color: rgba(255, 255, 255, 0.2); +} + +.sf-source-selector select:focus { + outline: none; + border-color: #6c5ce7; +} + +.sf-loading-bar { + height: 3px; + background: linear-gradient(90deg, #6c5ce7, #a29bfe, #6c5ce7); + background-size: 200% 100%; + animation: loading 1.5s infinite; + border-radius: 2px; + margin-bottom: 20px; +} + +@keyframes loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.sf-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 16px; +} + +.sf-btn { + padding: 10px 20px; + border-radius: 6px; + border: none; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.sf-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.sf-btn.primary { + background: #6c5ce7; +} + +.sf-btn.primary:hover:not(:disabled) { + background: #5b4cdb; +} + +.sf-empty { + text-align: center; + padding: 60px 20px; + color: #888; +} + +.sf-empty-icon { + font-size: 3rem; + margin-bottom: 16px; + display: block; +} + +.sf-empty p { + margin: 0; + font-size: 1rem; +} + +.sf-filters-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.sf-filter-item { + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 16px; + transition: all 0.2s ease; +} + +.sf-filter-item:hover { + background: rgba(255, 255, 255, 0.08); +} + +.sf-filter-item.disabled { + opacity: 0.6; +} + +.sf-filter-info { + flex: 1; +} + +.sf-filter-header { + display: flex; + align-items: center; + gap: 12px; +} + +.sf-filter-name { + color: #fff; + font-size: 1rem; + font-weight: 500; +} + +.sf-filter-kind { + background: rgba(108, 92, 231, 0.2); + color: #a29bfe; + padding: 2px 8px; + border-radius: 10px; + font-size: 0.75rem; +} + +.sf-disabled-badge { + display: inline-block; + background: rgba(231, 76, 60, 0.2); + color: #e74c3c; + padding: 2px 8px; + border-radius: 10px; + font-size: 0.75rem; + margin-top: 6px; +} + +.sf-filter-actions { + display: flex; + gap: 8px; +} + +.sf-icon-btn { + background: rgba(255, 255, 255, 0.1); + border: none; + color: #888; + width: 36px; + height: 36px; + border-radius: 8px; + cursor: pointer; + font-size: 1rem; + transition: all 0.2s ease; +} + +.sf-icon-btn:hover { + background: rgba(255, 255, 255, 0.2); +} + +.sf-icon-btn.active { + color: #2ecc71; +} + +.sf-icon-btn.danger:hover { + background: rgba(231, 76, 60, 0.2); + color: #e74c3c; +} + +/* Dialog */ +.sf-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.sf-dialog { + background: #1a1a2e; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + width: 90%; + max-width: 400px; + overflow: hidden; +} + +.sf-dialog-large { + max-width: 600px; +} + +.sf-dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.sf-dialog-header h3 { + margin: 0; + color: #fff; + font-size: 1.1rem; + font-weight: 500; +} + +.sf-dialog-close { + background: transparent; + border: none; + color: #888; + width: 28px; + height: 28px; + border-radius: 50%; + cursor: pointer; + font-size: 1rem; + transition: all 0.2s ease; +} + +.sf-dialog-close:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.sf-dialog-content { + padding: 20px; +} + +.sf-dialog-hint { + color: #888; + font-size: 0.85rem; + margin: 0 0 12px; +} + +.sf-field { + margin-bottom: 16px; +} + +.sf-field label { + display: block; + color: #888; + font-size: 0.85rem; + margin-bottom: 8px; +} + +.sf-field input, +.sf-field select { + width: 100%; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #fff; + padding: 12px; + border-radius: 8px; + font-size: 0.95rem; + transition: all 0.2s ease; +} + +.sf-field input:focus, +.sf-field select:focus { + outline: none; + border-color: #6c5ce7; +} + +.sf-field input::placeholder { + color: #555; +} + +.sf-json-input { + width: 100%; + min-height: 200px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #2ecc71; + padding: 12px; + border-radius: 8px; + font-family: 'Fira Code', 'Monaco', monospace; + font-size: 0.85rem; + resize: vertical; +} + +.sf-json-input:focus { + outline: none; + border-color: #6c5ce7; +} + +.sf-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 16px 20px; + background: rgba(0, 0, 0, 0.2); + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + +/* Warning */ +.sf-warning { + display: flex; + align-items: center; + gap: 10px; + background: rgba(243, 156, 18, 0.1); + border: 1px solid rgba(243, 156, 18, 0.3); + border-radius: 8px; + padding: 16px; + color: #f39c12; +} + +.sf-warning-icon { + font-size: 1.5rem; +} \ No newline at end of file diff --git a/src/components/SourceFilters.tsx b/src/components/SourceFilters.tsx new file mode 100644 index 0000000..b6683eb --- /dev/null +++ b/src/components/SourceFilters.tsx @@ -0,0 +1,350 @@ +/** + * V-Streaming Source Filters Component + * Manage audio/video filters for OBS sources + */ + +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useOBSWebSocket } from '../hooks/useOBSWebSocket'; +import './SourceFilters.css'; + +interface SourceFiltersProps { + onClose?: () => void; +} + +const COMMON_FILTER_KINDS: Record = { + 'color_filter': 'Color Correction', + 'color_key_filter': 'Color Key', + 'crop_filter': 'Crop/Pad', + 'gpu_delay_filter': 'Render Delay', + 'luma_key_filter': 'Luma Key', + 'mask_filter': 'Image Mask/Blend', + 'noise_gate_filter': 'Noise Gate', + 'noise_suppression_filter': 'Noise Suppression', + 'scale_filter': 'Scaling/Aspect Ratio', + 'scroll_filter': 'Scroll', + 'sharpness_filter': 'Sharpen', + 'chroma_key_filter': 'Chroma Key', + 'compressor_filter': 'Compressor', + 'limiter_filter': 'Limiter', + 'expander_filter': 'Expander', + 'gain_filter': 'Gain', + 'vst_filter': 'VST Plugin', +}; + +export const SourceFilters: React.FC = ({ onClose }) => { + const { t } = useTranslation(); + const { + isConnected, + sources, + getSources, + getSourceFilters, + createSourceFilter, + removeSourceFilter, + setSourceFilterEnabled, + setSourceFilterSettings, + } = useOBSWebSocket(); + + const [loading, setLoading] = useState(false); + const [selectedSource, setSelectedSource] = useState(''); + const [filters, setFilters] = useState([]); + const [showAddDialog, setShowAddDialog] = useState(false); + const [showEditDialog, setShowEditDialog] = useState(false); + const [newFilterName, setNewFilterName] = useState(''); + const [newFilterKind, setNewFilterKind] = useState(''); + const [editingFilter, setEditingFilter] = useState(null); + const [filterSettings, setFilterSettings] = useState>({}); + + useEffect(() => { + if (isConnected && !sources.length) { + loadSources(); + } + }, [isConnected]); + + useEffect(() => { + if (selectedSource) { + loadFilters(); + } + }, [selectedSource]); + + const loadSources = async () => { + setLoading(true); + try { + await getSources(); + } catch (error) { + console.error('Failed to load sources:', error); + } + setLoading(false); + }; + + const loadFilters = async () => { + if (!selectedSource) return; + setLoading(true); + try { + const result = await getSourceFilters(selectedSource); + setFilters(result || []); + } catch (error) { + console.error('Failed to load filters:', error); + setFilters([]); + } + setLoading(false); + }; + + const handleAddFilter = async () => { + if (!selectedSource || !newFilterName || !newFilterKind) return; + setLoading(true); + try { + await createSourceFilter(selectedSource, newFilterName, newFilterKind, filterSettings); + setShowAddDialog(false); + setNewFilterName(''); + setNewFilterKind(''); + setFilterSettings({}); + await loadFilters(); + } catch (error) { + console.error('Failed to add filter:', error); + } + setLoading(false); + }; + + const handleRemoveFilter = async (filterName: string) => { + if (!selectedSource) return; + setLoading(true); + try { + await removeSourceFilter(selectedSource, filterName); + await loadFilters(); + } catch (error) { + console.error('Failed to remove filter:', error); + } + setLoading(false); + }; + + const handleToggleFilter = async (filterName: string, enabled: boolean) => { + if (!selectedSource) return; + setLoading(true); + try { + await setSourceFilterEnabled(selectedSource, filterName, enabled); + await loadFilters(); + } catch (error) { + console.error('Failed to toggle filter:', error); + } + setLoading(false); + }; + + const handleEditFilter = (filter: any) => { + setEditingFilter(filter); + setFilterSettings(filter.filterSettings || {}); + setShowEditDialog(true); + }; + + const handleSaveFilterSettings = async () => { + if (!selectedSource || !editingFilter) return; + setLoading(true); + try { + await setSourceFilterSettings(selectedSource, editingFilter.filterName, filterSettings); + setShowEditDialog(false); + setEditingFilter(null); + setFilterSettings({}); + await loadFilters(); + } catch (error) { + console.error('Failed to save filter settings:', error); + } + setLoading(false); + }; + + if (!isConnected) { + return ( +
+
+ âš ī¸ + {t('obs.connectRequired', 'Connect to OBS to manage filters')} +
+
+ ); + } + + return ( +
+
+
+

{t('sf.title', 'Source Filters')}

+

{t('sf.subtitle', 'Manage audio and video filters for sources')}

+
+
+ + {onClose && ( + + )} +
+
+ +
+ + +
+ + {loading &&
} + + {selectedSource && ( + <> +
+ +
+ + {filters.length === 0 ? ( +
+ 🔇 +

{t('sf.noFilters', 'No filters applied to this source')}

+
+ ) : ( +
+ {filters.map((filter: any) => ( +
+
+
+ {filter.filterName} + {COMMON_FILTER_KINDS[filter.filterKind] || filter.filterKind} +
+ {!filter.filterEnabled && ( + {t('sf.disabled', 'Disabled')} + )} +
+
+ + + +
+
+ ))} +
+ )} + + )} + + {/* Add Filter Dialog */} + {showAddDialog && ( +
+
+
+

{t('sf.addFilter', 'Add Filter')}

+ +
+
+
+ + setNewFilterName(e.target.value)} + placeholder={t('sf.filterNamePlaceholder', 'Enter filter name')} + /> +
+
+ + +
+
+
+ + +
+
+
+ )} + + {/* Edit Filter Dialog */} + {showEditDialog && editingFilter && ( +
+
+
+

{t('sf.editFilter', 'Edit Filter')}: {editingFilter.filterName}

+ +
+
+

{t('sf.jsonHint', 'Filter settings are displayed as JSON. Edit the values below.')}

+