From e777b3d422837a34c8c2a82cf2706293271001e8 Mon Sep 17 00:00:00 2001 From: Decobus Date: Fri, 25 Jul 2025 20:50:05 -0400 Subject: [PATCH 1/7] Add studio mode support for scene switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect when OBS is in studio mode using GetStudioModeEnabled API - Switch preview scene instead of program scene when studio mode is active - Maintain backward compatibility for normal mode operation - Provide clear feedback indicating studio mode operation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- app/api/setScene/route.ts | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/app/api/setScene/route.ts b/app/api/setScene/route.ts index 548e003..2363d32 100644 --- a/app/api/setScene/route.ts +++ b/app/api/setScene/route.ts @@ -32,16 +32,30 @@ export async function POST(request: NextRequest) { try { const obsClient = await getOBSClient(); - // Switch to the requested scene - await obsClient.call('SetCurrentProgramScene', { sceneName }); + // Check if studio mode is active + const { studioModeEnabled } = await obsClient.call('GetStudioModeEnabled'); - console.log(`Successfully switched to scene: ${sceneName}`); - - return NextResponse.json({ - success: true, - data: { sceneName }, - message: `Switched to ${sceneName} layout` - }); + if (studioModeEnabled) { + // In studio mode, switch the preview scene + await obsClient.call('SetCurrentPreviewScene', { sceneName }); + console.log(`Successfully switched preview to scene: ${sceneName} (Studio Mode)`); + + return NextResponse.json({ + success: true, + data: { sceneName, studioMode: true }, + message: `Preview set to ${sceneName} layout (Studio Mode) - ready to transition` + }); + } else { + // Normal mode, switch program scene directly + await obsClient.call('SetCurrentProgramScene', { sceneName }); + console.log(`Successfully switched to scene: ${sceneName}`); + + return NextResponse.json({ + success: true, + data: { sceneName, studioMode: false }, + message: `Switched to ${sceneName} layout` + }); + } } catch (obsError) { console.error('OBS WebSocket error:', obsError); return NextResponse.json( From 07028b0792ea90376f88e85f0cc27cb1710e090b Mon Sep 17 00:00:00 2001 From: Decobus Date: Fri, 25 Jul 2025 20:52:42 -0400 Subject: [PATCH 2/7] Add studio mode status display and preview/program indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enhanced obsStatus API to include studio mode and preview scene information - Updated Footer component to show studio mode status (STUDIO/DIRECT) - Added preview scene display in footer when studio mode is enabled - Implemented dynamic scene button states showing Program/Preview/Both status - Scene buttons now clearly indicate preview vs program with distinct colors - Added proper state management for studio mode and preview scenes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- app/api/obsStatus/route.ts | 18 +++++ app/page.tsx | 154 +++++++++++++++++++++++++++---------- components/Footer.tsx | 18 ++++- 3 files changed, 147 insertions(+), 43 deletions(-) diff --git a/app/api/obsStatus/route.ts b/app/api/obsStatus/route.ts index 13e8dc4..91c85f6 100644 --- a/app/api/obsStatus/route.ts +++ b/app/api/obsStatus/route.ts @@ -19,9 +19,11 @@ export async function GET() { obsWebSocketVersion: string; }; currentScene?: string; + currentPreviewScene?: string; sceneCount?: number; streaming?: boolean; recording?: boolean; + studioModeEnabled?: boolean; error?: string; } = { host: OBS_HOST, @@ -58,15 +60,31 @@ export async function GET() { // Get recording status const recordStatus = await obs.call('GetRecordStatus'); + // Get studio mode status + const studioModeStatus = await obs.call('GetStudioModeEnabled'); + + // Get preview scene if studio mode is enabled + let currentPreviewScene; + if (studioModeStatus.studioModeEnabled) { + try { + const previewSceneInfo = await obs.call('GetCurrentPreviewScene'); + currentPreviewScene = previewSceneInfo.sceneName; + } catch (previewError) { + console.log('Could not get preview scene:', previewError); + } + } + connectionStatus.connected = true; connectionStatus.version = { obsVersion: versionInfo.obsVersion, obsWebSocketVersion: versionInfo.obsWebSocketVersion }; connectionStatus.currentScene = currentSceneInfo.sceneName; + connectionStatus.currentPreviewScene = currentPreviewScene; connectionStatus.sceneCount = sceneList.scenes.length; connectionStatus.streaming = streamStatus.outputActive; connectionStatus.recording = recordStatus.outputActive; + connectionStatus.studioModeEnabled = studioModeStatus.studioModeEnabled; } catch (err) { connectionStatus.error = err instanceof Error ? err.message : 'Unknown error occurred'; diff --git a/app/page.tsx b/app/page.tsx index 7094cd5..b2e5d82 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -19,6 +19,8 @@ export default function Home() { const [isLoading, setIsLoading] = useState(true); const [openDropdown, setOpenDropdown] = useState(null); const [currentScene, setCurrentScene] = useState(null); + const [currentPreviewScene, setCurrentPreviewScene] = useState(null); + const [studioModeEnabled, setStudioModeEnabled] = useState(false); const { toasts, removeToast, showSuccess, showError } = useToast(); // Memoized active source lookup for performance @@ -59,17 +61,19 @@ export default function Home() { const fetchData = useCallback(async () => { const endTimer = PerformanceMonitor.startTimer('fetchData'); try { - // Fetch streams, active sources, and current scene in parallel - const [streamsRes, activeRes, sceneRes] = await Promise.all([ + // Fetch streams, active sources, current scene, and OBS status in parallel + const [streamsRes, activeRes, sceneRes, obsStatusRes] = await Promise.all([ fetch('/api/streams'), fetch('/api/getActive'), - fetch('/api/getCurrentScene') + fetch('/api/getCurrentScene'), + fetch('/api/obsStatus') ]); - const [streamsData, activeData, sceneData] = await Promise.all([ + const [streamsData, activeData, sceneData, obsStatusData] = await Promise.all([ streamsRes.json(), activeRes.json(), - sceneRes.json() + sceneRes.json(), + obsStatusRes.json() ]); // Handle both old and new API response formats @@ -80,6 +84,15 @@ export default function Home() { setStreams(streams); setActiveSources(activeSources); setCurrentScene(sceneName); + + // Update studio mode and preview scene from OBS status + if (obsStatusData.connected) { + setStudioModeEnabled(obsStatusData.studioModeEnabled || false); + setCurrentPreviewScene(obsStatusData.currentPreviewScene || null); + } else { + setStudioModeEnabled(false); + setCurrentPreviewScene(null); + } } catch (error) { console.error('Error fetching data:', error); showError('Failed to Load Data', 'Could not fetch streams. Please refresh the page.'); @@ -126,9 +139,16 @@ export default function Home() { const result = await response.json(); if (result.success) { - // Update local state immediately for responsive UI - setCurrentScene(sceneName); - showSuccess('Scene Changed', `Switched to ${sceneName} layout`); + // Update local state based on studio mode + if (result.data.studioMode) { + // In studio mode, update preview scene + setCurrentPreviewScene(sceneName); + showSuccess('Preview Set', result.message); + } else { + // In normal mode, update program scene + setCurrentScene(sceneName); + showSuccess('Scene Changed', `Switched to ${sceneName} layout`); + } } else { throw new Error(result.error || 'Failed to switch scene'); } @@ -138,6 +158,55 @@ export default function Home() { } }, [showSuccess, showError]); + // Helper function to get scene button state and styling + const getSceneButtonState = useCallback((sceneName: string) => { + const isProgram = currentScene === sceneName; + const isPreview = studioModeEnabled && currentPreviewScene === sceneName; + + if (studioModeEnabled) { + if (isProgram && isPreview) { + return { + isActive: true, + text: `Program & Preview: ${sceneName}`, + background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))' + }; + } else if (isProgram) { + return { + isActive: true, + text: `Program: ${sceneName}`, + background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))' + }; + } else if (isPreview) { + return { + isActive: true, + text: `Preview: ${sceneName}`, + background: 'linear-gradient(135deg, var(--solarized-yellow), var(--solarized-orange))' + }; + } else { + return { + isActive: false, + text: `Set Preview: ${sceneName}`, + background: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))' + }; + } + } else { + // Normal mode + if (isProgram) { + return { + isActive: true, + text: `Active: ${sceneName}`, + background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))' + }; + } else { + return { + isActive: false, + text: `Switch to ${sceneName}`, + background: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))' + }; + } + } + }, [currentScene, currentPreviewScene, studioModeEnabled]); + // Memoized corner displays to prevent re-renders const cornerDisplays = useMemo(() => [ { screen: 'top_left' as const, label: 'Top Left' }, @@ -182,17 +251,18 @@ export default function Home() {

Primary Display

- + {(() => { + const buttonState = getSceneButtonState('1-Screen'); + return ( + + ); + })()}

Side Displays

- + {(() => { + const buttonState = getSceneButtonState('2-Screen'); + return ( + + ); + })()}
@@ -252,17 +323,18 @@ export default function Home() {

Corner Displays

- + {(() => { + const buttonState = getSceneButtonState('4-Screen'); + return ( + + ); + })()}
{cornerDisplays.map(({ screen, label }) => ( diff --git a/components/Footer.tsx b/components/Footer.tsx index edbcb26..883daff 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -13,9 +13,11 @@ type OBSStatus = { obsWebSocketVersion: string; }; currentScene?: string; + currentPreviewScene?: string; sceneCount?: number; streaming?: boolean; recording?: boolean; + studioModeEnabled?: boolean; error?: string; }; @@ -96,7 +98,7 @@ export default function Footer() {
{obsStatus.host}:{obsStatus.port}
{obsStatus.hasPassword &&
🔒 Authenticated
} - {/* Streaming/Recording Status */} + {/* Streaming/Recording/Studio Mode Status */} {obsStatus.connected && (
@@ -108,6 +110,11 @@ export default function Footer() {
{obsStatus.recording ? 'REC' : 'IDLE'}
+ +
+
+ {obsStatus.studioModeEnabled ? 'STUDIO' : 'DIRECT'} +
)}
@@ -138,11 +145,18 @@ export default function Footer() {
{obsStatus.currentScene && (
- Scene: + {obsStatus.studioModeEnabled ? 'Program:' : 'Scene:'} {obsStatus.currentScene}
)} + {obsStatus.studioModeEnabled && obsStatus.currentPreviewScene && ( +
+ Preview: + {obsStatus.currentPreviewScene} +
+ )} + {obsStatus.sceneCount !== null && (
Total Scenes: From 3bad71cb26296c5fb9007f81031384bab7eff581 Mon Sep 17 00:00:00 2001 From: Decobus Date: Fri, 25 Jul 2025 21:29:23 -0400 Subject: [PATCH 3/7] Add comprehensive studio mode support and stream organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement studio mode transition workflow with Go Live buttons - Add collapsible team grouping for better stream organization - Include source locking functionality for newly created streams - Enhance footer status indicators with improved visual styling - Create triggerTransition API endpoint for studio mode operations - Add CollapsibleGroup component for expandable content sections 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- app/api/addStream/route.ts | 5 +- app/api/triggerTransition/route.ts | 62 ++++++++ app/globals.css | 97 ++++++++++++ app/page.tsx | 166 +++++++++++++++------ app/streams/page.tsx | 231 ++++++++++++++++++++++------- components/CollapsibleGroup.tsx | 64 ++++++++ components/Footer.tsx | 24 +-- lib/obsClient.js | 70 ++++++++- 8 files changed, 603 insertions(+), 116 deletions(-) create mode 100644 app/api/triggerTransition/route.ts create mode 100644 components/CollapsibleGroup.tsx diff --git a/app/api/addStream/route.ts b/app/api/addStream/route.ts index 9347106..75a62a3 100644 --- a/app/api/addStream/route.ts +++ b/app/api/addStream/route.ts @@ -63,7 +63,7 @@ function generateOBSSourceName(teamSceneName: string, streamName: string): strin } export async function POST(request: NextRequest) { - let name: string, url: string, team_id: number, obs_source_name: string; + let name: string, url: string, team_id: number, obs_source_name: string, lockSources: boolean; // Parse and validate request body try { @@ -78,6 +78,7 @@ export async function POST(request: NextRequest) { } ({ name, url, team_id } = validation.data!); + lockSources = body.lockSources !== false; // Default to true if not specified } catch { return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); @@ -125,7 +126,7 @@ export async function POST(request: NextRequest) { if (!sourceExists) { // Create stream group with text overlay - await createStreamGroup(groupName, name, teamInfo.team_name, url); + await createStreamGroup(groupName, name, teamInfo.team_name, url, lockSources); // Update team with group UUID if not set if (!teamInfo.group_uuid) { diff --git a/app/api/triggerTransition/route.ts b/app/api/triggerTransition/route.ts new file mode 100644 index 0000000..7136863 --- /dev/null +++ b/app/api/triggerTransition/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server'; +import { getOBSClient } from '../../../lib/obsClient'; + +export async function POST() { + try { + const obsClient = await getOBSClient(); + + // Check if studio mode is active + const { studioModeEnabled } = await obsClient.call('GetStudioModeEnabled'); + + if (!studioModeEnabled) { + return NextResponse.json( + { + success: false, + error: 'Studio mode is not enabled', + message: 'Studio mode must be enabled to trigger transitions' + }, + { status: 400 } + ); + } + + try { + // Trigger the studio mode transition (preview to program) + await obsClient.call('TriggerStudioModeTransition'); + console.log('Successfully triggered studio mode transition'); + + // Get the updated scene information after transition + const [programResponse, previewResponse] = await Promise.all([ + obsClient.call('GetCurrentProgramScene'), + obsClient.call('GetCurrentPreviewScene') + ]); + + return NextResponse.json({ + success: true, + data: { + programScene: programResponse.currentProgramSceneName, + previewScene: previewResponse.currentPreviewSceneName + }, + message: 'Successfully transitioned preview to program' + }); + } catch (obsError) { + console.error('OBS WebSocket error during transition:', obsError); + return NextResponse.json( + { + success: false, + error: 'Failed to trigger transition in OBS', + details: obsError instanceof Error ? obsError.message : 'Unknown error' + }, + { status: 500 } + ); + } + } catch (error) { + console.error('Error triggering transition:', error); + return NextResponse.json( + { + success: false, + error: 'Failed to connect to OBS or trigger transition' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index e1107df..25229a3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -241,6 +241,28 @@ body { position: absolute; transform: translateZ(0); will-change: transform; + max-height: 400px; + overflow-y: auto; +} + +/* Custom scrollbar for dropdown */ +.dropdown-menu::-webkit-scrollbar { + width: 8px; +} + +.dropdown-menu::-webkit-scrollbar-track { + background: rgba(7, 54, 66, 0.5); + border-radius: 4px; +} + +.dropdown-menu::-webkit-scrollbar-thumb { + background: rgba(88, 110, 117, 0.6); + border-radius: 4px; + transition: background 0.2s ease; +} + +.dropdown-menu::-webkit-scrollbar-thumb:hover { + background: rgba(88, 110, 117, 0.8); } .dropdown-item { @@ -366,4 +388,79 @@ body { .p-8 { padding: 32px; +} + +/* Collapsible Group Styles */ +.collapsible-group { + margin-bottom: 16px; +} + +.collapsible-header { + width: 100%; + background: rgba(7, 54, 66, 0.3); + border: 1px solid rgba(88, 110, 117, 0.3); + border-radius: 12px; + padding: 16px 20px; + cursor: pointer; + transition: all 0.3s ease; + margin-bottom: 1px; +} + +.collapsible-header:hover { + background: rgba(7, 54, 66, 0.5); + border-color: rgba(131, 148, 150, 0.4); +} + +.collapsible-header-content { + display: flex; + align-items: center; + gap: 12px; +} + +.collapsible-icon { + flex-shrink: 0; + transition: transform 0.3s ease; + color: #93a1a1; +} + +.collapsible-icon.open { + transform: rotate(90deg); +} + +.collapsible-title { + flex: 1; + font-size: 18px; + font-weight: 600; + color: #fdf6e3; + text-align: left; + margin: 0; +} + +.collapsible-count { + background: rgba(38, 139, 210, 0.2); + color: #268bd2; + padding: 4px 12px; + border-radius: 20px; + font-size: 14px; + font-weight: 600; +} + +.collapsible-content { + overflow: hidden; + transition: all 0.3s ease; + max-height: 0; + opacity: 0; +} + +.collapsible-content.open { + max-height: 5000px; + opacity: 1; + margin-top: 12px; +} + +.collapsible-content-inner { + padding: 20px; + background: rgba(7, 54, 66, 0.2); + border: 1px solid rgba(88, 110, 117, 0.2); + border-radius: 12px; } \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index b2e5d82..1c3e65e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -158,6 +158,31 @@ export default function Home() { } }, [showSuccess, showError]); + const handleTransition = useCallback(async () => { + try { + const response = await fetch('/api/triggerTransition', { + method: 'POST', + }); + + const result = await response.json(); + + if (result.success) { + // Update local state after successful transition + setCurrentScene(result.data.programScene); + setCurrentPreviewScene(result.data.previewScene); + showSuccess('Transition Complete', 'Successfully transitioned preview to program'); + + // Refresh data to ensure UI is in sync + fetchData(); + } else { + throw new Error(result.error || 'Failed to trigger transition'); + } + } catch (error) { + console.error('Error triggering transition:', error); + showError('Transition Failed', error instanceof Error ? error.message : 'Could not trigger transition. Please try again.'); + } + }, [showSuccess, showError, fetchData]); + // Helper function to get scene button state and styling const getSceneButtonState = useCallback((sceneName: string) => { const isProgram = currentScene === sceneName; @@ -168,25 +193,29 @@ export default function Home() { return { isActive: true, text: `Program & Preview: ${sceneName}`, - background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))' + background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))', + showTransition: false }; } else if (isProgram) { return { isActive: true, text: `Program: ${sceneName}`, - background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))' + background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))', + showTransition: false }; } else if (isPreview) { return { isActive: true, text: `Preview: ${sceneName}`, - background: 'linear-gradient(135deg, var(--solarized-yellow), var(--solarized-orange))' + background: 'linear-gradient(135deg, var(--solarized-yellow), var(--solarized-orange))', + showTransition: true }; } else { return { isActive: false, text: `Set Preview: ${sceneName}`, - background: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))' + background: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))', + showTransition: false }; } } else { @@ -195,13 +224,15 @@ export default function Home() { return { isActive: true, text: `Active: ${sceneName}`, - background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))' + background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))', + showTransition: false }; } else { return { isActive: false, text: `Switch to ${sceneName}`, - background: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))' + background: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))', + showTransition: false }; } } @@ -251,18 +282,35 @@ export default function Home() {

Primary Display

- {(() => { - const buttonState = getSceneButtonState('1-Screen'); - return ( - - ); - })()} +
+ {(() => { + const buttonState = getSceneButtonState('1-Screen'); + return ( + <> + + {buttonState.showTransition && ( + + )} + + ); + })()} +

Side Displays

- {(() => { - const buttonState = getSceneButtonState('2-Screen'); - return ( - - ); - })()} +
+ {(() => { + const buttonState = getSceneButtonState('2-Screen'); + return ( + <> + + {buttonState.showTransition && ( + + )} + + ); + })()} +
@@ -323,18 +388,35 @@ export default function Home() {

Corner Displays

- {(() => { - const buttonState = getSceneButtonState('4-Screen'); - return ( - - ); - })()} +
+ {(() => { + const buttonState = getSceneButtonState('4-Screen'); + return ( + <> + + {buttonState.showTransition && ( + + )} + + ); + })()} +
{cornerDisplays.map(({ screen, label }) => ( diff --git a/app/streams/page.tsx b/app/streams/page.tsx index 2e4f52f..e3d55cc 100644 --- a/app/streams/page.tsx +++ b/app/streams/page.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import Dropdown from '@/components/Dropdown'; +import CollapsibleGroup from '@/components/CollapsibleGroup'; import { Team } from '@/types'; import { useToast } from '@/lib/useToast'; import { ToastContainer } from '@/components/Toast'; @@ -14,6 +15,174 @@ interface Stream { team_id: number; } +interface StreamsByTeamProps { + streams: Stream[]; + teams: {id: number; name: string}[]; + onDelete: (stream: Stream) => void; +} + +function StreamsByTeam({ streams, teams, onDelete }: StreamsByTeamProps) { + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [useCustomExpanded, setUseCustomExpanded] = useState(false); + + // Group streams by team + const streamsByTeam = useMemo(() => { + const grouped = new Map(); + + // Initialize with all teams + teams.forEach(team => { + grouped.set(team.id, []); + }); + + // Add "No Team" group for streams without a team + grouped.set(-1, []); + + // Group streams + streams.forEach(stream => { + const teamId = stream.team_id || -1; + const teamStreams = grouped.get(teamId) || []; + teamStreams.push(stream); + grouped.set(teamId, teamStreams); + }); + + // Only include groups that have streams + const result: Array<{teamId: number; teamName: string; streams: Stream[]}> = []; + + grouped.forEach((streamList, teamId) => { + if (streamList.length > 0) { + const team = teams.find(t => t.id === teamId); + result.push({ + teamId, + teamName: teamId === -1 ? 'No Team' : (team?.name || 'Unknown Team'), + streams: streamList + }); + } + }); + + // Sort by team name, with "No Team" at the end + result.sort((a, b) => { + if (a.teamId === -1) return 1; + if (b.teamId === -1) return -1; + return a.teamName.localeCompare(b.teamName); + }); + + return result; + }, [streams, teams]); + + const handleExpandAll = () => { + const allIds = streamsByTeam.map(group => group.teamId); + setExpandedGroups(new Set(allIds)); + setUseCustomExpanded(true); + }; + + const handleCollapseAll = () => { + setExpandedGroups(new Set()); + setUseCustomExpanded(true); + }; + + const handleToggleGroup = (teamId: number) => { + const newExpanded = new Set(expandedGroups); + if (newExpanded.has(teamId)) { + newExpanded.delete(teamId); + } else { + newExpanded.add(teamId); + } + setExpandedGroups(newExpanded); + setUseCustomExpanded(true); + }; + + return ( +
+ {streamsByTeam.length > 0 && ( +
+ + +
+ )} +
+ {streamsByTeam.map(({ teamId, teamName, streams: teamStreams }) => ( + handleToggleGroup(teamId)} + > +
+ {teamStreams.map((stream) => ( +
+
+
+
+ {stream.name.charAt(0).toUpperCase()} +
+
+
{stream.name}
+
OBS: {stream.obs_source_name}
+
+
+
+
ID: {stream.id}
+
+ + + + + View Stream + + +
+
+
+
+ ))} +
+
+ ))} +
+
+ ); +} + export default function AddStream() { const [formData, setFormData] = useState({ name: '', @@ -312,61 +481,11 @@ export default function AddStream() {
Create your first stream above!
) : ( -
- {streams.map((stream) => { - const team = teams.find(t => t.id === stream.team_id); - return ( -
-
-
-
- {stream.name.charAt(0).toUpperCase()} -
-
-
{stream.name}
-
OBS: {stream.obs_source_name}
-
Team: {team?.name || 'Unknown'}
-
-
-
-
ID: {stream.id}
-
- - - - - View Stream - - -
-
-
-
- ); - })} -
+ setDeleteConfirm({ id: stream.id, name: stream.name })} + /> )}
diff --git a/components/CollapsibleGroup.tsx b/components/CollapsibleGroup.tsx new file mode 100644 index 0000000..0ea3c54 --- /dev/null +++ b/components/CollapsibleGroup.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useState, ReactNode } from 'react'; + +interface CollapsibleGroupProps { + title: string; + itemCount: number; + children: ReactNode; + defaultOpen?: boolean; + isOpen?: boolean; + onToggle?: () => void; +} + +export default function CollapsibleGroup({ + title, + itemCount, + children, + defaultOpen = true, + isOpen: controlledIsOpen, + onToggle +}: CollapsibleGroupProps) { + const [internalIsOpen, setInternalIsOpen] = useState(defaultOpen); + + // Use controlled state if provided, otherwise use internal state + const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen; + + const handleToggle = () => { + if (onToggle) { + onToggle(); + } else { + setInternalIsOpen(!internalIsOpen); + } + }; + + return ( +
+ + +
+
+ {children} +
+
+
+ ); +} \ No newline at end of file diff --git a/components/Footer.tsx b/components/Footer.tsx index 883daff..6537386 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -85,8 +85,8 @@ export default function Footer() {

OBS Studio

-
-
+
+

{obsStatus?.connected ? 'Connected' : 'Disconnected'}

@@ -100,20 +100,20 @@ export default function Footer() { {/* Streaming/Recording/Studio Mode Status */} {obsStatus.connected && ( -
-
-
- {obsStatus.streaming ? 'LIVE' : 'OFFLINE'} +
+
+
+ {obsStatus.streaming ? 'LIVE' : 'OFFLINE'}
-
-
- {obsStatus.recording ? 'REC' : 'IDLE'} +
+
+ {obsStatus.recording ? 'REC' : 'IDLE'}
-
-
- {obsStatus.studioModeEnabled ? 'STUDIO' : 'DIRECT'} +
+
+ {obsStatus.studioModeEnabled ? 'STUDIO' : 'DIRECT'}
)} diff --git a/lib/obsClient.js b/lib/obsClient.js index 1232244..1aa6709 100644 --- a/lib/obsClient.js +++ b/lib/obsClient.js @@ -363,7 +363,7 @@ async function createTextSource(sceneName, textSourceName, text) { } } -async function createStreamGroup(groupName, streamName, teamName, url) { +async function createStreamGroup(groupName, streamName, teamName, url, lockSources = true) { try { const obsClient = await getOBSClient(); @@ -443,14 +443,48 @@ async function createStreamGroup(groupName, streamName, teamName, url) { } catch (muteError) { console.error(`Failed to mute browser source audio for "${sourceName}":`, muteError.message); } + + // Lock the newly created browser source if requested + if (lockSources) { + try { + // Get the scene items to find the browser source's ID + const { sceneItems } = await obsClient.call('GetSceneItemList', { sceneName: streamGroupName }); + const browserItem = sceneItems.find(item => item.sourceName === sourceName); + + if (browserItem) { + await obsClient.call('SetSceneItemLocked', { + sceneName: streamGroupName, + sceneItemId: browserItem.sceneItemId, + sceneItemLocked: true + }); + console.log(`Locked browser source "${sourceName}" in nested scene`); + } + } catch (lockError) { + console.error(`Failed to lock browser source "${sourceName}":`, lockError.message); + } + } } else { // Add existing source to nested scene - await obsClient.call('CreateSceneItem', { + const { sceneItemId } = await obsClient.call('CreateSceneItem', { sceneName: streamGroupName, sourceName: sourceName }); console.log(`Added existing browser source "${sourceName}" to nested scene`); + // Lock the scene item if requested + if (lockSources) { + try { + await obsClient.call('SetSceneItemLocked', { + sceneName: streamGroupName, + sceneItemId: sceneItemId, + sceneItemLocked: true + }); + console.log(`Locked browser source "${sourceName}" in nested scene`); + } catch (lockError) { + console.error(`Failed to lock browser source "${sourceName}":`, lockError.message); + } + } + // Ensure existing browser source has audio control enabled and correct URL try { await obsClient.call('SetInputSettings', { @@ -482,21 +516,49 @@ async function createStreamGroup(groupName, streamName, teamName, url) { const colorSourceName = `${textSourceName}_bg`; try { - await obsClient.call('CreateSceneItem', { + const { sceneItemId: colorItemId } = await obsClient.call('CreateSceneItem', { sceneName: streamGroupName, sourceName: colorSourceName }); console.log(`Added color source background "${colorSourceName}" to nested scene`); + + // Lock the color source if requested + if (lockSources) { + try { + await obsClient.call('SetSceneItemLocked', { + sceneName: streamGroupName, + sceneItemId: colorItemId, + sceneItemLocked: true + }); + console.log(`Locked color source background "${colorSourceName}"`); + } catch (lockError) { + console.error(`Failed to lock color source:`, lockError.message); + } + } } catch (error) { console.log('Color source background might already be in nested scene'); } try { - await obsClient.call('CreateSceneItem', { + const { sceneItemId: textItemId } = await obsClient.call('CreateSceneItem', { sceneName: streamGroupName, sourceName: textSourceName }); console.log(`Added text source "${textSourceName}" to nested scene`); + + // Lock the text source if requested + if (lockSources) { + try { + await obsClient.call('SetSceneItemLocked', { + sceneName: streamGroupName, + sceneItemId: textItemId, + sceneItemLocked: true + }); + console.log(`Locked text source "${textSourceName}"`); + } catch (lockError) { + console.error(`Failed to lock text source:`, lockError.message); + } + } } catch (error) { console.log('Text source might already be in nested scene'); } From 4f9e6d2097f845f5bf17e2ab593f818cd5b2c5f2 Mon Sep 17 00:00:00 2001 From: Decobus Date: Fri, 25 Jul 2025 21:39:49 -0400 Subject: [PATCH 4/7] Consolidate CSS architecture and eliminate repetition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CSS custom properties for commonly used gradients - Consolidate duplicate button variants (.btn.active and .btn-success) - Replace inline gradient styles with semantic CSS classes - Standardize spacing with utility classes (mr-1, mr-2, mr-4, ml-3) - Remove unused Home.module.css file - Replace hard-coded colors with Solarized CSS variables - Add scene-specific button classes (btn-scene-preview, btn-scene-transition) Reduces CSS duplication, improves maintainability, and ensures consistent styling throughout the application with reusable utility classes. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- app/globals.css | 86 +++++++++++++++++++++++++++--------------- app/page.tsx | 42 ++++++--------------- app/streams/page.tsx | 8 ++-- components/Footer.tsx | 12 +++--- styles/Home.module.css | 10 ----- 5 files changed, 77 insertions(+), 81 deletions(-) delete mode 100644 styles/Home.module.css diff --git a/app/globals.css b/app/globals.css index 25229a3..ae33ffe 100644 --- a/app/globals.css +++ b/app/globals.css @@ -20,6 +20,14 @@ --solarized-red: #dc322f; --solarized-magenta: #d33682; --solarized-violet: #6c71c4; + + /* Gradient Custom Properties */ + --gradient-primary: linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan)); + --gradient-active: linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow)); + --gradient-danger: linear-gradient(135deg, var(--solarized-red), var(--solarized-orange)); + --gradient-preview: linear-gradient(135deg, var(--solarized-yellow), var(--solarized-orange)); + --gradient-transition: linear-gradient(135deg, var(--solarized-red), var(--solarized-magenta)); + --gradient-body: linear-gradient(135deg, #002b36 0%, #073642 50%, #002b36 100%); } /* Modern CSS Foundation */ @@ -34,8 +42,8 @@ html { } body { - background: linear-gradient(135deg, #002b36 0%, #073642 50%, #002b36 100%); - color: #93a1a1; + background: var(--gradient-body); + color: var(--solarized-base1); min-height: 100vh; line-height: 1.6; } @@ -75,8 +83,8 @@ body { /* Modern Button System */ .btn { - background: linear-gradient(135deg, #268bd2, #2aa198); - color: #fdf6e3; + background: var(--gradient-primary); + color: var(--solarized-base3); border: none; padding: 12px 24px; border-radius: 12px; @@ -93,9 +101,10 @@ body { text-decoration: none; } -.btn.active { - background: linear-gradient(135deg, #859900, #b58900); - color: #fdf6e3; +.btn.active, +.btn-success { + background: var(--gradient-active); + color: var(--solarized-base3); box-shadow: 0 0 0 3px rgba(133, 153, 0, 0.5); transform: translateY(-1px); font-weight: 700; @@ -117,7 +126,7 @@ body { .btn-secondary { background: rgba(88, 110, 117, 0.3); border: 1px solid rgba(131, 148, 150, 0.4); - color: #93a1a1; + color: var(--solarized-base1); backdrop-filter: blur(10px); } @@ -127,25 +136,14 @@ body { box-shadow: 0 6px 20px rgba(88, 110, 117, 0.3); } -/* Success Button */ -.btn-success { - background: linear-gradient(135deg, #859900, #b58900); - color: #fdf6e3; -} - -.btn-success:hover { - background: linear-gradient(135deg, #b58900, #859900); - box-shadow: 0 6px 20px rgba(133, 153, 0, 0.4); -} - /* Danger Button */ .btn-danger { - background: linear-gradient(135deg, #dc322f, #cb4b16); - color: #fdf6e3; + background: var(--gradient-danger); + color: var(--solarized-base3); } .btn-danger:hover { - background: linear-gradient(135deg, #cb4b16, #dc322f); + background: linear-gradient(135deg, var(--solarized-orange), var(--solarized-red)); box-shadow: 0 6px 20px rgba(220, 50, 47, 0.4); } @@ -169,6 +167,18 @@ body { flex-shrink: 0; } +/* Scene Button Variants */ +.btn-scene-preview { + background: var(--gradient-preview); + color: var(--solarized-base3); +} + +.btn-scene-transition { + background: var(--gradient-transition); + color: var(--solarized-base3); + min-width: 120px; +} + /* Form spacing fixes since Tailwind gap classes aren't working */ .form-row { display: flex; @@ -194,7 +204,7 @@ body { border: 1px solid rgba(88, 110, 117, 0.4); border-radius: 12px; padding: 12px 16px; - color: #93a1a1; + color: var(--solarized-base1); width: 100%; transition: all 0.3s ease; } @@ -205,7 +215,7 @@ body { .input:focus { outline: none; - border-color: #268bd2; + border-color: var(--solarized-blue); box-shadow: 0 0 0 3px rgba(38, 139, 210, 0.2); } @@ -215,7 +225,7 @@ body { border: 1px solid rgba(88, 110, 117, 0.4); border-radius: 12px; padding: 12px 16px; - color: #93a1a1; + color: var(--solarized-base1); width: 100%; text-align: left; cursor: pointer; @@ -270,7 +280,7 @@ body { cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid rgba(88, 110, 117, 0.2); - color: #93a1a1; + color: var(--solarized-base1); } .dropdown-item:last-child { @@ -283,7 +293,7 @@ body { .dropdown-item.active { background: rgba(38, 139, 210, 0.3); - color: #fdf6e3; + color: var(--solarized-base3); } /* Icon Sizes */ @@ -378,6 +388,22 @@ body { margin-bottom: 32px; } +.ml-3 { + margin-left: 12px; +} + +.mr-1 { + margin-right: 4px; +} + +.mr-2 { + margin-right: 8px; +} + +.mr-4 { + margin-right: 16px; +} + .p-4 { padding: 16px; } @@ -420,7 +446,7 @@ body { .collapsible-icon { flex-shrink: 0; transition: transform 0.3s ease; - color: #93a1a1; + color: var(--solarized-base1); } .collapsible-icon.open { @@ -431,14 +457,14 @@ body { flex: 1; font-size: 18px; font-weight: 600; - color: #fdf6e3; + color: var(--solarized-base3); text-align: left; margin: 0; } .collapsible-count { background: rgba(38, 139, 210, 0.2); - color: #268bd2; + color: var(--solarized-blue); padding: 4px 12px; border-radius: 20px; font-size: 14px; diff --git a/app/page.tsx b/app/page.tsx index 1c3e65e..3ed4197 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -193,28 +193,28 @@ export default function Home() { return { isActive: true, text: `Program & Preview: ${sceneName}`, - background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))', + className: 'active', showTransition: false }; } else if (isProgram) { return { isActive: true, text: `Program: ${sceneName}`, - background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))', + className: 'active', showTransition: false }; } else if (isPreview) { return { isActive: true, text: `Preview: ${sceneName}`, - background: 'linear-gradient(135deg, var(--solarized-yellow), var(--solarized-orange))', + className: 'btn-scene-preview', showTransition: true }; } else { return { isActive: false, text: `Set Preview: ${sceneName}`, - background: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))', + className: '', showTransition: false }; } @@ -224,14 +224,14 @@ export default function Home() { return { isActive: true, text: `Active: ${sceneName}`, - background: 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))', + className: 'active', showTransition: false }; } else { return { isActive: false, text: `Switch to ${sceneName}`, - background: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))', + className: '', showTransition: false }; } @@ -289,20 +289,14 @@ export default function Home() { <> {buttonState.showTransition && ( @@ -335,20 +329,14 @@ export default function Home() { <> {buttonState.showTransition && ( @@ -395,20 +383,14 @@ export default function Home() { <> {buttonState.showTransition && ( diff --git a/app/streams/page.tsx b/app/streams/page.tsx index e3d55cc..fb1896c 100644 --- a/app/streams/page.tsx +++ b/app/streams/page.tsx @@ -131,12 +131,11 @@ function StreamsByTeam({ streams, teams, onDelete }: StreamsByTeamProps) {
{stream.name.charAt(0).toUpperCase()} @@ -153,8 +152,7 @@ function StreamsByTeam({ streams, teams, onDelete }: StreamsByTeamProps) { href={stream.url} target="_blank" rel="noopener noreferrer" - className="btn btn-primary text-sm" - style={{ marginRight: '8px' }} + className="btn btn-primary text-sm mr-2" > diff --git a/components/Footer.tsx b/components/Footer.tsx index 6537386..d7c4f77 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -86,7 +86,7 @@ export default function Footer() {

OBS Studio

-
+

{obsStatus?.connected ? 'Connected' : 'Disconnected'}

@@ -102,17 +102,17 @@ export default function Footer() { {obsStatus.connected && (
-
- {obsStatus.streaming ? 'LIVE' : 'OFFLINE'} +
+ {obsStatus.streaming ? 'LIVE' : 'OFFLINE'}
-
- {obsStatus.recording ? 'REC' : 'IDLE'} +
+ {obsStatus.recording ? 'REC' : 'IDLE'}
-
+
{obsStatus.studioModeEnabled ? 'STUDIO' : 'DIRECT'}
diff --git a/styles/Home.module.css b/styles/Home.module.css deleted file mode 100644 index c890880..0000000 --- a/styles/Home.module.css +++ /dev/null @@ -1,10 +0,0 @@ -.linkButton { - padding: 10px 20px; - background: #0070f3; - color: #fff; - text-decoration: none; - border-radius: 5px; - display: inline-block; - text-align: center; - } - \ No newline at end of file From bc4cfe607d972e6ee85acfba9a73e19cb0141a15 Mon Sep 17 00:00:00 2001 From: Decobus Date: Sat, 26 Jul 2025 00:19:16 -0400 Subject: [PATCH 5/7] Add API key authentication for external access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create API key context for managing authentication state - Add dedicated settings page for API key management - Move performance metrics to dedicated page in navigation - Update middleware to support URL parameter fallback - Enhance UI with proper glass morphism styling - Add Solarized color utilities to CSS - Improve spacing and padding throughout UI components - Remove manual bullet points from list items 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- app/globals.css | 60 ++++++++++- app/layout.tsx | 19 ++-- app/performance/page.tsx | 125 ++++++++++++++++++++++ app/settings/page.tsx | 158 ++++++++++++++++++++++++++++ components/ApiKeyPrompt.tsx | 152 ++++++++++++++++++++++++++ components/Header.tsx | 18 ++++ components/PerformanceDashboard.tsx | 68 ++++++------ contexts/ApiKeyContext.tsx | 57 ++++++++++ lib/apiClient.ts | 7 +- middleware.ts | 4 +- 10 files changed, 620 insertions(+), 48 deletions(-) create mode 100644 app/performance/page.tsx create mode 100644 app/settings/page.tsx create mode 100644 components/ApiKeyPrompt.tsx create mode 100644 contexts/ApiKeyContext.tsx diff --git a/app/globals.css b/app/globals.css index ae33ffe..dac679e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -73,7 +73,8 @@ body { } /* Glass Card Component */ -.glass { +.glass, +.glass-panel { background: rgba(7, 54, 66, 0.4); backdrop-filter: blur(10px); border: 1px solid rgba(88, 110, 117, 0.3); @@ -489,4 +490,59 @@ body { background: rgba(7, 54, 66, 0.2); border: 1px solid rgba(88, 110, 117, 0.2); border-radius: 12px; -} \ No newline at end of file +} + +/* Solarized Color Utilities */ +.text-base03 { color: var(--solarized-base03); } +.text-base02 { color: var(--solarized-base02); } +.text-base01 { color: var(--solarized-base01); } +.text-base00 { color: var(--solarized-base00); } +.text-base0 { color: var(--solarized-base0); } +.text-base1 { color: var(--solarized-base1); } +.text-base2 { color: var(--solarized-base2); } +.text-base3 { color: var(--solarized-base3); } +.text-blue { color: var(--solarized-blue); } +.text-cyan { color: var(--solarized-cyan); } +.text-green { color: var(--solarized-green); } +.text-yellow { color: var(--solarized-yellow); } +.text-orange { color: var(--solarized-orange); } +.text-red { color: var(--solarized-red); } +.text-magenta { color: var(--solarized-magenta); } +.text-violet { color: var(--solarized-violet); } + +.bg-base03 { background-color: var(--solarized-base03); } +.bg-base02 { background-color: var(--solarized-base02); } +.bg-base01 { background-color: var(--solarized-base01); } +.bg-base00 { background-color: var(--solarized-base00); } +.bg-base0 { background-color: var(--solarized-base0); } +.bg-base1 { background-color: var(--solarized-base1); } +.bg-base2 { background-color: var(--solarized-base2); } +.bg-base3 { background-color: var(--solarized-base3); } +.bg-blue { background-color: var(--solarized-blue); } +.bg-cyan { background-color: var(--solarized-cyan); } +.bg-green { background-color: var(--solarized-green); } +.bg-yellow { background-color: var(--solarized-yellow); } +.bg-orange { background-color: var(--solarized-orange); } +.bg-red { background-color: var(--solarized-red); } +.bg-magenta { background-color: var(--solarized-magenta); } +.bg-violet { background-color: var(--solarized-violet); } + +.border-base01 { border-color: var(--solarized-base01); } +.border-base02 { border-color: var(--solarized-base02); } +.border-blue { border-color: var(--solarized-blue); } +.border-cyan { border-color: var(--solarized-cyan); } +.border-green { border-color: var(--solarized-green); } +.border-yellow { border-color: var(--solarized-yellow); } +.border-orange { border-color: var(--solarized-orange); } +.border-red { border-color: var(--solarized-red); } + +/* Border opacity utilities */ +.border-green\/30 { border-color: rgba(133, 153, 0, 0.3); } +.border-yellow\/30 { border-color: rgba(181, 137, 0, 0.3); } +.border-blue\/30 { border-color: rgba(38, 139, 210, 0.3); } +.border-red\/30 { border-color: rgba(220, 50, 47, 0.3); } + +/* Focus utilities */ +.focus\:outline-none:focus { outline: none; } +.focus\:ring-2:focus { box-shadow: 0 0 0 2px var(--solarized-blue); } +.focus\:ring-blue:focus { box-shadow: 0 0 0 2px var(--solarized-blue); } \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index c3ab1bc..4b3dbbc 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,7 +2,7 @@ import './globals.css'; import Header from '@/components/Header'; import Footer from '@/components/Footer'; import { ErrorBoundary } from '@/components/ErrorBoundary'; -import PerformanceDashboard from '@/components/PerformanceDashboard'; +import { ApiKeyProvider } from '@/contexts/ApiKeyContext'; export const metadata = { title: 'Live Stream Manager', @@ -13,14 +13,15 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( -
-
- - {children} - -
-
- + +
+
+ + {children} + +
+
+ ); diff --git a/app/performance/page.tsx b/app/performance/page.tsx new file mode 100644 index 0000000..4db20db --- /dev/null +++ b/app/performance/page.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { PerformanceMonitor } from '@/lib/performance'; + +interface PerformanceMetrics { + [key: string]: { + avg: number; + min: number; + max: number; + count: number; + } | null; +} + +export default function PerformancePage() { + const [metrics, setMetrics] = useState({}); + + useEffect(() => { + const updateMetrics = () => { + setMetrics(PerformanceMonitor.getAllMetrics()); + }; + + // Update metrics every 2 seconds + updateMetrics(); + const interval = setInterval(updateMetrics, 2000); + + return () => clearInterval(interval); + }, []); + + return ( +
+
+

Performance Metrics

+ + {Object.keys(metrics).length === 0 ? ( +
+

No metrics collected yet. Navigate around the app to see performance data.

+
+ ) : ( +
+ {Object.entries(metrics).map(([label, metric]) => { + if (!metric) return null; + + return ( +
+

+ {label.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} +

+ +
+
+ Average + {metric.avg.toFixed(2)}ms +
+ +
+ Min + {metric.min.toFixed(2)}ms +
+ +
+ Max + 100 ? 'text-red' : 'text-yellow'}`}> + {metric.max.toFixed(2)}ms + +
+ +
+ Count + {metric.count} +
+
+ + {/* Performance indicator bar */} +
+
+
+
+
+ + {metric.avg < 50 ? 'Excellent' : + metric.avg < 100 ? 'Good' : 'Needs Optimization'} + +
+
+
+ ); + })} + + {/* Performance tips */} +
+

💡 Performance Tips

+
+
+

Response Times

+
    +
  • < 50ms - Excellent user experience
  • +
  • 50-100ms - Good, barely noticeable
  • +
  • 100-300ms - Noticeable delay
  • +
  • > 300ms - Frustrating for users
  • +
+
+
+

Optimization Strategies

+
    +
  • Monitor fetchData and setActive timings
  • +
  • High max values indicate performance spikes
  • +
  • Consider caching for frequently called APIs
  • +
  • Batch multiple requests when possible
  • +
+
+
+
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..07f070b --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { useState } from 'react'; +import { useApiKey } from '@/contexts/ApiKeyContext'; + +export default function SettingsPage() { + const { apiKey, setApiKey, clearApiKey, isAuthenticated } = useApiKey(); + const [inputValue, setInputValue] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setSuccess(''); + setIsLoading(true); + + if (!inputValue.trim()) { + setError('API key is required'); + setIsLoading(false); + return; + } + + // Test the API key by making a simple request + try { + const response = await fetch('/api/obsStatus', { + headers: { + 'x-api-key': inputValue.trim() + } + }); + + if (response.ok) { + setApiKey(inputValue.trim()); + setInputValue(''); + setSuccess('API key saved successfully!'); + } else { + setError('Invalid API key'); + } + } catch { + setError('Failed to validate API key'); + } finally { + setIsLoading(false); + } + }; + + const handleClearKey = () => { + clearApiKey(); + setInputValue(''); + setError(''); + setSuccess('API key cleared'); + }; + + return ( +
+
+

Settings

+ + {/* API Key Section */} +
+
+

API Key Authentication

+

+ API keys are required when accessing this application from external networks. + The key is stored securely in your browser's local storage. +

+
+ + {/* Current Status */} +
+
+
+

Current Status

+
+ {isAuthenticated ? ( + <> +
+ Authenticated + + ) : ( + <> +
+ No API key set + + )} +
+
+ {isAuthenticated && ( + + )} +
+
+ + {/* API Key Form */} +
+
+ + setInputValue(e.target.value)} + className="w-full text-white focus:outline-none focus:ring-2 focus:ring-blue transition-all" + style={{ + padding: '12px 24px', + background: 'rgba(7, 54, 66, 0.4)', + backdropFilter: 'blur(10px)', + border: '1px solid rgba(88, 110, 117, 0.3)', + borderRadius: '12px', + fontSize: '16px' + }} + placeholder="Enter your API key" + /> +
+ + {error && ( +
+

{error}

+
+ )} + + {success && ( +
+

{success}

+
+ )} + + +
+ + {/* Information Section */} +
+

â„šī¸ Information

+
    +
  • API keys are only required for external network access
  • +
  • Local network access bypasses authentication automatically
  • +
  • Keys are validated against the server before saving
  • +
  • Your API key is stored locally and never transmitted unnecessarily
  • +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/components/ApiKeyPrompt.tsx b/components/ApiKeyPrompt.tsx new file mode 100644 index 0000000..a42611a --- /dev/null +++ b/components/ApiKeyPrompt.tsx @@ -0,0 +1,152 @@ +'use client'; + +import React, { useState } from 'react'; +import { useApiKey } from '../contexts/ApiKeyContext'; + +interface ApiKeyPromptProps { + show: boolean; + onClose?: () => void; +} + +export function ApiKeyPrompt({ show, onClose }: ApiKeyPromptProps) { + const { setApiKey } = useApiKey(); + const [inputValue, setInputValue] = useState(''); + const [error, setError] = useState(''); + + if (!show) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!inputValue.trim()) { + setError('API key is required'); + return; + } + + // Test the API key by making a simple request + try { + const response = await fetch('/api/obsStatus', { + headers: { + 'x-api-key': inputValue.trim() + } + }); + + if (response.ok) { + setApiKey(inputValue.trim()); + setInputValue(''); + onClose?.(); + } else { + setError('Invalid API key'); + } + } catch { + setError('Failed to validate API key'); + } + }; + + return ( +
+
+

API Key Required

+

+ This application requires an API key for access. Please enter your API key to continue. +

+ +
+
+ + setInputValue(e.target.value)} + className="w-full text-white focus:outline-none focus:ring-2 focus:ring-blue transition-all" + style={{ + padding: '12px 24px', + background: 'rgba(7, 54, 66, 0.4)', + backdropFilter: 'blur(10px)', + border: '1px solid rgba(88, 110, 117, 0.3)', + borderRadius: '12px', + fontSize: '16px' + }} + placeholder="Enter your API key" + autoFocus + /> + {error && ( +

{error}

+ )} +
+ +
+ + {onClose && ( + + )} +
+
+
+
+ ); +} + +export function ApiKeyBanner() { + const { isAuthenticated, clearApiKey } = useApiKey(); + const [showPrompt, setShowPrompt] = useState(false); + + if (isAuthenticated) { + return ( +
+ + ✓ + Authenticated + +
+ + +
+ setShowPrompt(false)} /> +
+ ); + } + + return ( + <> +
+ + âš ī¸ + API key required for full access + + +
+ setShowPrompt(false)} /> + + ); +} \ No newline at end of file diff --git a/components/Header.tsx b/components/Header.tsx index 44f8cc6..5c4d9ec 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -50,6 +50,24 @@ export default function Header() { đŸ‘Ĩ Teams + + + âš™ī¸ + Settings + + + {process.env.NODE_ENV === 'development' && ( + + 📊 + Perf + + )}
diff --git a/components/PerformanceDashboard.tsx b/components/PerformanceDashboard.tsx index cfb96ed..b18c95c 100644 --- a/components/PerformanceDashboard.tsx +++ b/components/PerformanceDashboard.tsx @@ -36,22 +36,27 @@ export default function PerformanceDashboard() { } return ( -
- {!isVisible ? ( - - ) : ( -
+ <> + {!isVisible && ( +
+ +
+ )} + + {isVisible && ( +
+
-

Performance Metrics

+

Performance Metrics

{Object.keys(metrics).length === 0 ? ( -

No metrics collected yet.

+

No metrics collected yet.

) : (
{Object.entries(metrics).map(([label, metric]) => { if (!metric) return null; return ( -
-

+
+

{label.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}

- Avg:{' '} - {metric.avg.toFixed(2)}ms + Avg:{' '} + {metric.avg.toFixed(2)}ms
- Count:{' '} - {metric.count} + Count:{' '} + {metric.count}
- Min:{' '} - {metric.min.toFixed(2)}ms + Min:{' '} + {metric.min.toFixed(2)}ms
- Max:{' '} - 100 ? 'text-red-400' : 'text-yellow-400'}> + Max:{' '} + 100 ? 'text-red' : 'text-yellow'}> {metric.max.toFixed(2)}ms
@@ -96,11 +101,11 @@ export default function PerformanceDashboard() {
- + {metric.avg < 50 ? 'Excellent' : metric.avg < 100 ? 'Good' : 'Needs Optimization'} @@ -111,11 +116,11 @@ export default function PerformanceDashboard() { })} {/* Performance tips */} -
-

+
+

💡 Performance Tips

-
    +
    • â€ĸ Keep API calls under 100ms for optimal UX
    • â€ĸ Monitor fetchData and setActive timings
    • â€ĸ High max values indicate performance spikes
    • @@ -124,8 +129,9 @@ export default function PerformanceDashboard() {

)} +
)} -
+ ); } \ No newline at end of file diff --git a/contexts/ApiKeyContext.tsx b/contexts/ApiKeyContext.tsx new file mode 100644 index 0000000..829d266 --- /dev/null +++ b/contexts/ApiKeyContext.tsx @@ -0,0 +1,57 @@ +'use client'; + +import React, { createContext, useContext, useState, useEffect } from 'react'; + +interface ApiKeyContextType { + apiKey: string | null; + setApiKey: (key: string) => void; + clearApiKey: () => void; + isAuthenticated: boolean; +} + +const ApiKeyContext = createContext(undefined); + +export function ApiKeyProvider({ children }: { children: React.ReactNode }) { + const [apiKey, setApiKeyState] = useState(null); + const [isLoaded, setIsLoaded] = useState(false); + + // Load API key from localStorage on mount + useEffect(() => { + const stored = localStorage.getItem('obs-api-key'); + if (stored) { + setApiKeyState(stored); + } + setIsLoaded(true); + }, []); + + const setApiKey = (key: string) => { + localStorage.setItem('obs-api-key', key); + setApiKeyState(key); + }; + + const clearApiKey = () => { + localStorage.removeItem('obs-api-key'); + setApiKeyState(null); + }; + + const isAuthenticated = Boolean(apiKey); + + // Don't render children until we've loaded the API key from storage + if (!isLoaded) { + return
Loading...
; + } + + return ( + + {children} + + ); +} + +export function useApiKey() { + const context = useContext(ApiKeyContext); + if (context === undefined) { + throw new Error('useApiKey must be used within an ApiKeyProvider'); + } + return context; +} \ No newline at end of file diff --git a/lib/apiClient.ts b/lib/apiClient.ts index 5d386c7..41170b6 100644 --- a/lib/apiClient.ts +++ b/lib/apiClient.ts @@ -1,14 +1,13 @@ // API client utility for making authenticated requests -// Get API key from environment (client-side will need to be provided differently) +// Get API key from environment or localStorage function getApiKey(): string | null { if (typeof window === 'undefined') { // Server-side return process.env.API_KEY || null; } else { - // Client-side - for now, return null to bypass auth in development - // In production, this would come from a secure storage or context - return null; + // Client-side - get from localStorage + return localStorage.getItem('obs-api-key') || null; } } diff --git a/middleware.ts b/middleware.ts index 8361919..a20b5db 100644 --- a/middleware.ts +++ b/middleware.ts @@ -8,8 +8,8 @@ export function middleware(request: NextRequest) { return NextResponse.next(); } - // Check for API key in header - const apiKey = request.headers.get('x-api-key'); + // Check for API key in header or URL parameter + const apiKey = request.headers.get('x-api-key') || request.nextUrl.searchParams.get('apikey'); const validKey = process.env.API_KEY; // If API_KEY is not set in environment, skip authentication (development mode) From 0edead505f4d055610d2bd07abdafe9ae80fadf4 Mon Sep 17 00:00:00 2001 From: Decobus Date: Sat, 26 Jul 2025 00:24:42 -0400 Subject: [PATCH 6/7] Update documentation to reflect recent enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add studio mode support with preview/program controls and transition API - Document API key authentication middleware for production security - Add collapsible stream groups and consolidated CSS architecture - Split API documentation into separate docs/API.md file for better organization - Update feature descriptions and endpoint specifications - Include new system scenes and enhanced footer functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 23 ++++- README.md | 63 ++++-------- docs/API.md | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 324 insertions(+), 50 deletions(-) create mode 100644 docs/API.md diff --git a/CLAUDE.md b/CLAUDE.md index 2bda7ca..17bd6d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,10 +23,11 @@ This is a Next.js web application (branded as "Live Stream Manager") that contro ### Technology Stack - **Frontend**: Next.js 15.1.6 with React 19, TypeScript, and custom CSS with glass morphism design -- **Backend**: Next.js API routes +- **Backend**: Next.js API routes with authentication middleware - **Database**: SQLite with sqlite3 driver - **OBS Integration**: obs-websocket-js for WebSocket communication with OBS Studio -- **Styling**: Solarized Dark theme with CSS custom properties, Tailwind CSS utilities, and accessible glass morphism components +- **Styling**: Consolidated CSS architecture with Solarized Dark theme, CSS custom properties, Tailwind CSS utilities, and accessible glass morphism components +- **Security**: API key authentication middleware for production deployments ### Project Structure - `/app` - Next.js App Router pages and API routes @@ -34,7 +35,8 @@ This is a Next.js web application (branded as "Live Stream Manager") that contro - `/streams` - Streams management page (add new streams and view existing) - `/teams` - Team management page - `/edit/[id]` - Individual stream editing -- `/components` - Reusable React components (Header, Footer, Dropdown, Toast) +- `/components` - Reusable React components (Header, Footer, Dropdown, Toast, CollapsibleGroup) +- `/middleware.ts` - API authentication middleware for security - `/lib` - Core utilities and database connection - `database.ts` - SQLite database initialization and connection management - `obsClient.js` - OBS WebSocket client with persistent connection management @@ -76,6 +78,10 @@ This is a Next.js web application (branded as "Live Stream Manager") that contro 10. **OBS Scene Control**: Direct scene switching controls with dynamic state tracking and real-time synchronization between UI and OBS +11. **Studio Mode Support**: Full preview/program scene management with transition controls for professional broadcasting + +12. **Collapsible Stream Groups**: Organized stream display with expandable team groups for better UI management + ### Environment Configuration - `FILE_DIRECTORY`: Directory for database and text files (default: ./files) - `OBS_WEBSOCKET_HOST`: OBS WebSocket host (default: 127.0.0.1) @@ -117,9 +123,10 @@ This is a Next.js web application (branded as "Live Stream Manager") that contro #### OBS Scene Control - `POST /api/setScene` - Switch OBS to specified scene layout (1-Screen, 2-Screen, 4-Screen) - `GET /api/getCurrentScene` - Get currently active OBS scene for state synchronization +- `POST /api/triggerTransition` - Trigger studio mode transition from preview to program (requires studio mode enabled) #### System Status -- `GET /api/obsStatus` - Real-time OBS connection and streaming status +- `GET /api/obsStatus` - Real-time OBS connection, streaming, recording, and studio mode status ### Database Schema @@ -188,6 +195,9 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio ### Security Architecture **Authentication**: API key-based authentication protects all API endpoints through Next.js middleware +- Middleware intercepts all API requests when `API_KEY` is set +- Bypasses authentication for localhost in development +- Returns 401 for unauthorized requests **Input Validation**: Comprehensive validation using centralized security utilities in `/lib/security.ts`: - Screen parameter allowlisting prevents path traversal attacks @@ -212,6 +222,8 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio - **Visual Feedback**: Clear "View Stream" links with proper contrast for accessibility - **Team Association**: Streams organized under teams with proper naming conventions - **Active Source Detection**: Properly reads current active sources from text files on page load and navigation +- **Collapsible Organization**: Streams grouped by team in expandable sections for cleaner UI +- **Enhanced Stream Display**: Shows stream status with preview/program indicators in studio mode ### Team & Group Management - **UUID-based Tracking**: Robust OBS group synchronization using scene UUIDs @@ -247,12 +259,15 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio - **Error Recovery**: Graceful error handling with user-friendly messages - **Enhanced Footer**: Real-time team/stream counts, OBS connection status with visual indicators - **Optimistic Updates**: Immediate UI feedback with proper stream group name matching +- **Studio Mode Status**: Footer displays studio mode state with preview/program scene information +- **Transition Controls**: "Cut to Preview" button available when studio mode is active ### OBS Integration Improvements - **Text Size**: Team name overlays use 96pt font for better visibility - **Color Display**: Fixed background color display (#002b4b) using proper ABGR format - **Standardized APIs**: All endpoints use consistent `{ success: true, data: [...] }` response format - **Performance Optimization**: Reduced code duplication and improved API response handling +- **CSS Consolidation**: Eliminated repetitive styles, centralized theming in globals.css ### Developer Experience - **Type Safety**: Comprehensive TypeScript definitions throughout diff --git a/README.md b/README.md index d44ace2..ce5b8e9 100644 --- a/README.md +++ b/README.md @@ -7,19 +7,22 @@ A professional [Next.js](https://nextjs.org) web application for managing live s ## Features +- **Studio Mode Support**: Full preview/program scene management with transition controls for professional broadcasting - **OBS Scene Control**: Switch between OBS layouts (1-Screen, 2-Screen, 4-Screen) with dynamic button states - **Multi-Screen Source Control**: Manage 7 different screen positions (large, left, right, and 4 corners) - **Real-time OBS Integration**: WebSocket connection with live status monitoring - **Enhanced Stream Management**: Create, edit, and delete streams with comprehensive OBS cleanup - **Team Organization**: Organize streams by teams with full CRUD operations and scene synchronization +- **Collapsible Stream Groups**: Organized stream display with expandable team groups for better UI management - **Comprehensive Deletion**: Remove streams/teams with complete OBS component cleanup (scenes, sources, text files) - **Audio Control**: Browser sources created with muted audio and OBS control enabled - **Modern UI**: Glass morphism design with responsive layout and accessibility features - **Professional Broadcasting**: Audio routing, scene management, and live status indicators - **Dual Integration**: WebSocket API + text file monitoring for maximum compatibility - **UUID-based Tracking**: Robust OBS group synchronization with rename-safe tracking -- **Enhanced Footer**: Real-time team/stream counts and OBS connection status -- **Optimized Performance**: Reduced code duplication and standardized API responses +- **Enhanced Footer**: Real-time team/stream counts, OBS connection status, and studio mode indicators +- **API Security**: Optional API key authentication for production deployments +- **Optimized Performance**: Consolidated CSS architecture and standardized API responses ## Quick Start @@ -109,54 +112,22 @@ npm run type-check # TypeScript validation - **Styling**: Custom CSS with glass morphism and Tailwind utilities - **CI/CD**: Forgejo workflows with self-hosted runners -## API Endpoints +## API Documentation -### Stream Management -- `GET /api/streams` - List all streams with team information -- `GET /api/streams/[id]` - Get individual stream details -- `POST /api/addStream` - Create new stream with browser source and team association -- `PUT /api/streams/[id]` - Update stream information -- `DELETE /api/streams/[id]` - Delete stream with comprehensive OBS cleanup: - - Removes stream's nested scene - - Deletes browser source - - Removes from all source switchers - - Clears text files referencing the stream +The application provides a comprehensive REST API for managing streams, teams, and OBS integration. -### Source Control -- `POST /api/setActive` - Set active stream for screen position (writes team-prefixed name to text file) -- `GET /api/getActive` - Get currently active sources for all screen positions +**📚 [Complete API Documentation](docs/API.md)** -### Team Management -- `GET /api/teams` - Get all teams with group information and sync status -- `POST /api/teams` - Create new team with optional OBS scene creation -- `PUT /api/teams/[teamId]` - Update team name, group_name, or group_uuid -- `DELETE /api/teams/[teamId]` - Delete team with comprehensive OBS cleanup: - - Deletes team scene/group - - Removes team text source - - Deletes all associated stream scenes - - Removes all browser sources with team prefix - - Clears all related text files -- `GET /api/getTeamName` - Get team name by ID +Key endpoints include: +- Stream management (CRUD operations) +- Source control for 7 screen positions +- Team and OBS group management +- Scene switching and studio mode controls +- Real-time status monitoring -### OBS Group/Scene Management -- `POST /api/createGroup` - Create OBS scene from team and store UUID -- `POST /api/syncGroups` - Synchronize all teams with OBS groups -- `GET /api/verifyGroups` - Verify database groups exist in OBS with UUID tracking - - Detects orphaned groups (excludes system scenes) - - Identifies name mismatches - - Shows sync status for all teams +All endpoints support API key authentication for production deployments. -### OBS Scene Control -- `POST /api/setScene` - Switch OBS to specified scene (1-Screen, 2-Screen, 4-Screen) -- `GET /api/getCurrentScene` - Get currently active OBS scene - -### System Status -- `GET /api/obsStatus` - Real-time OBS connection, streaming, and recording status - -### Authentication -All endpoints require API key authentication when `API_KEY` environment variable is set. - -See `CLAUDE.md` for detailed architecture documentation and implementation details. +See [`CLAUDE.md`](CLAUDE.md) for detailed architecture documentation and [`docs/API.md`](docs/API.md) for complete endpoint specifications. ## Known Issues @@ -167,7 +138,7 @@ See `CLAUDE.md` for detailed architecture documentation and implementation detai ### System Scene Exclusion Infrastructure scenes containing source switchers are excluded from orphaned group detection: -- 1-Screen, 2-Screen, 4-Screen, Starting, Ending, Audio, Movies +- 1-Screen, 2-Screen, 4-Screen, Starting, Ending, Audio, Movies, Resources - Additional scenes can be added to the `SYSTEM_SCENES` array in `/app/api/verifyGroups/route.ts` diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..be61781 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,288 @@ +# API Documentation + +This document provides detailed information about all API endpoints available in the Live Stream Manager application. + +## Base URL +All API endpoints are available at `/api/*` relative to your application's base URL. + +## Authentication +All endpoints require API key authentication when the `API_KEY` environment variable is set. Include the API key in the `Authorization` header: + +``` +Authorization: Bearer your_api_key_here +``` + +Authentication is bypassed for localhost requests in development mode. + +## Response Format +All endpoints return JSON responses in the following format: + +```json +{ + "success": true, + "data": { /* response data */ }, + "message": "Optional success message" +} +``` + +Error responses: +```json +{ + "success": false, + "error": "Error description", + "message": "User-friendly error message" +} +``` + +## Stream Management + +### GET /api/streams +List all streams with team information. + +**Response:** +```json +{ + "success": true, + "data": [ + { + "id": 1, + "name": "StreamerName", + "obs_source_name": "TeamName_StreamerName", + "url": "https://twitch.tv/streamername", + "team_id": 1, + "team_name": "Team Alpha" + } + ] +} +``` + +### GET /api/streams/[id] +Get individual stream details by ID. + +### POST /api/addStream +Create new stream with browser source and team association. + +**Request Body:** +```json +{ + "name": "StreamerName", + "url": "https://twitch.tv/streamername", // or just "streamername" + "team_id": 1 +} +``` + +### PUT /api/streams/[id] +Update stream information. + +### DELETE /api/streams/[id] +Delete stream with comprehensive OBS cleanup: +- Removes stream's nested scene +- Deletes browser source +- Removes from all source switchers +- Clears text files referencing the stream + +## Source Control + +### POST /api/setActive +Set active stream for screen position (writes team-prefixed name to text file). + +**Request Body:** +```json +{ + "screen": "large", // large, left, right, top_left, top_right, bottom_left, bottom_right + "source": "TeamName_StreamerName" +} +``` + +### GET /api/getActive +Get currently active sources for all screen positions. + +**Response:** +```json +{ + "success": true, + "data": { + "large": "TeamName_StreamerName", + "left": "TeamName_StreamerName2", + "right": "", + // ... other positions + } +} +``` + +## Team Management + +### GET /api/teams +Get all teams with group information and sync status. + +**Response:** +```json +{ + "success": true, + "data": [ + { + "team_id": 1, + "team_name": "Team Alpha", + "group_name": "Team Alpha", + "group_uuid": "abc123-def456-ghi789" + } + ] +} +``` + +### POST /api/teams +Create new team with optional OBS scene creation. + +**Request Body:** +```json +{ + "team_name": "New Team", + "create_group": true // optional +} +``` + +### PUT /api/teams/[teamId] +Update team name, group_name, or group_uuid. + +### DELETE /api/teams/[teamId] +Delete team with comprehensive OBS cleanup: +- Deletes team scene/group +- Removes team text source +- Deletes all associated stream scenes +- Removes all browser sources with team prefix +- Clears all related text files + +### GET /api/getTeamName +Get team name by ID. + +**Query Parameters:** +- `teamId`: Team ID to lookup + +## OBS Group/Scene Management + +### POST /api/createGroup +Create OBS scene from team and store UUID. + +**Request Body:** +```json +{ + "team_id": 1 +} +``` + +### POST /api/syncGroups +Synchronize all teams with OBS groups. Updates database with current OBS scene information. + +### GET /api/verifyGroups +Verify database groups exist in OBS with UUID tracking. + +**Response:** +```json +{ + "success": true, + "data": { + "teams": [ + { + "team_id": 1, + "team_name": "Team Alpha", + "group_name": "Team Alpha", + "group_uuid": "abc123", + "status": "linked", // linked, name_changed, not_found + "obs_name": "Team Alpha Modified" // if name changed in OBS + } + ], + "orphanedGroups": [ + { + "sceneName": "Orphaned Scene", + "sceneUuid": "orphan123" + } + ] + } +} +``` + +Features: +- Detects orphaned groups (excludes system scenes) +- Identifies name mismatches +- Shows sync status for all teams + +## OBS Scene Control + +### POST /api/setScene +Switch OBS to specified scene (1-Screen, 2-Screen, 4-Screen). + +**Request Body:** +```json +{ + "scene": "2-Screen" // 1-Screen, 2-Screen, or 4-Screen +} +``` + +### GET /api/getCurrentScene +Get currently active OBS scene. + +**Response:** +```json +{ + "success": true, + "data": { + "currentScene": "2-Screen" + } +} +``` + +### POST /api/triggerTransition +Trigger studio mode transition from preview to program (requires studio mode enabled). + +**Response:** +```json +{ + "success": true, + "data": { + "programScene": "2-Screen", + "previewScene": "1-Screen" + }, + "message": "Successfully transitioned preview to program" +} +``` + +**Error Conditions:** +- Studio mode not enabled (400 error) +- OBS connection issues (500 error) + +## System Status + +### GET /api/obsStatus +Real-time OBS connection, streaming, recording, and studio mode status. + +**Response:** +```json +{ + "success": true, + "data": { + "connected": true, + "streaming": false, + "recording": true, + "studioMode": { + "enabled": true, + "previewScene": "1-Screen", + "programScene": "2-Screen" + } + } +} +``` + +## Error Codes + +- **400**: Bad Request - Invalid parameters or studio mode not enabled +- **401**: Unauthorized - Missing or invalid API key +- **404**: Not Found - Resource doesn't exist +- **500**: Internal Server Error - OBS connection issues or server errors + +## Rate Limiting + +Currently no rate limiting is implemented, but consider implementing it for production deployments to prevent abuse. + +## WebSocket Integration + +The application maintains a persistent WebSocket connection to OBS Studio for real-time communication. All API endpoints use this shared connection for optimal performance. \ No newline at end of file From b974de37e8f12baba55f92b1c8167af88faaa811 Mon Sep 17 00:00:00 2001 From: Decobus Date: Sat, 26 Jul 2025 00:26:41 -0400 Subject: [PATCH 7/7] Fix ESLint warnings and errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused imports in test files - Fix unused variable warnings - Fix React unescaped entity warning in settings page - Remove unused error parameters in catch blocks 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- app/api/__tests__/streams.test.ts | 2 +- app/api/__tests__/teams.test.ts | 4 ++-- app/settings/page.tsx | 4 ++-- lib/obsClient.js | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/api/__tests__/streams.test.ts b/app/api/__tests__/streams.test.ts index 41f6389..1e1eea1 100644 --- a/app/api/__tests__/streams.test.ts +++ b/app/api/__tests__/streams.test.ts @@ -1,4 +1,4 @@ -import { GET } from '../streams/route'; +// import { GET } from '../streams/route'; // Mock the database module jest.mock('@/lib/database', () => ({ diff --git a/app/api/__tests__/teams.test.ts b/app/api/__tests__/teams.test.ts index bd09053..d7e431b 100644 --- a/app/api/__tests__/teams.test.ts +++ b/app/api/__tests__/teams.test.ts @@ -1,4 +1,4 @@ -import { GET } from '../teams/route'; +// import { GET } from '../teams/route'; // Mock the database module jest.mock('@/lib/database', () => ({ @@ -13,7 +13,7 @@ jest.mock('@/lib/apiHelpers', () => ({ status, json: async () => ({ success: true, data }), })), - createDatabaseError: jest.fn((operation, error) => ({ + createDatabaseError: jest.fn((operation) => ({ error: 'Database Error', status: 500, json: async () => ({ diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 07f070b..13d2692 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { useApiKey } from '@/contexts/ApiKeyContext'; export default function SettingsPage() { - const { apiKey, setApiKey, clearApiKey, isAuthenticated } = useApiKey(); + const { setApiKey, clearApiKey, isAuthenticated } = useApiKey(); const [inputValue, setInputValue] = useState(''); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); @@ -62,7 +62,7 @@ export default function SettingsPage() {

API Key Authentication

API keys are required when accessing this application from external networks. - The key is stored securely in your browser's local storage. + The key is stored securely in your browser's local storage.

diff --git a/lib/obsClient.js b/lib/obsClient.js index 1aa6709..9910fb5 100644 --- a/lib/obsClient.js +++ b/lib/obsClient.js @@ -380,7 +380,7 @@ async function createStreamGroup(groupName, streamName, teamName, url, lockSourc try { await obsClient.call('CreateScene', { sceneName: streamGroupName }); console.log(`Created nested scene "${streamGroupName}" for stream grouping`); - } catch (error) { + } catch { console.log(`Nested scene "${streamGroupName}" might already exist`); } @@ -535,7 +535,7 @@ async function createStreamGroup(groupName, streamName, teamName, url, lockSourc console.error(`Failed to lock color source:`, lockError.message); } } - } catch (error) { + } catch { console.log('Color source background might already be in nested scene'); } @@ -559,7 +559,7 @@ async function createStreamGroup(groupName, streamName, teamName, url, lockSourc console.error(`Failed to lock text source:`, lockError.message); } } - } catch (error) { + } catch { console.log('Text source might already be in nested scene'); }