From 260eb3f7b2c4ded9b6bdff4a7340e95f4cf1d26a Mon Sep 17 00:00:00 2001 From: Decobus Date: Tue, 22 Jul 2025 18:52:49 -0400 Subject: [PATCH] Add OBS scene switching controls with dynamic button states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add setScene API endpoint for OBS scene switching (1-Screen, 2-Screen, 4-Screen) - Add getCurrentScene API endpoint to fetch active OBS scene - Implement scene switching buttons in main UI with dynamic state tracking - Buttons change color and text based on current active scene - Glass morphism styling with Solarized Dark gradients - Real-time scene state synchronization with optimistic UI updates - Toast notifications for user feedback 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- app/api/getCurrentScene/route.ts | 30 ++++++++ app/api/setScene/route.ts | 66 ++++++++++++++++ app/page.tsx | 126 ++++++++++++++++++++++++------- 3 files changed, 196 insertions(+), 26 deletions(-) create mode 100644 app/api/getCurrentScene/route.ts create mode 100644 app/api/setScene/route.ts diff --git a/app/api/getCurrentScene/route.ts b/app/api/getCurrentScene/route.ts new file mode 100644 index 0000000..ce6a78d --- /dev/null +++ b/app/api/getCurrentScene/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; +import { getOBSClient } from '../../../lib/obsClient'; + +export async function GET() { + try { + const obsClient = await getOBSClient(); + + // Get the current program scene + const response = await obsClient.call('GetCurrentProgramScene'); + const { currentProgramSceneName } = response; + + console.log(`Current OBS scene: ${currentProgramSceneName}`); + + return NextResponse.json({ + success: true, + data: { sceneName: currentProgramSceneName }, + message: 'Current scene retrieved successfully' + }); + } catch (obsError) { + console.error('OBS WebSocket error:', obsError); + return NextResponse.json( + { + success: false, + error: 'Failed to get current scene from OBS', + details: obsError instanceof Error ? obsError.message : 'Unknown error' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/setScene/route.ts b/app/api/setScene/route.ts new file mode 100644 index 0000000..548e003 --- /dev/null +++ b/app/api/setScene/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getOBSClient } from '../../../lib/obsClient'; + +// Valid scene names for this application +const VALID_SCENES = ['1-Screen', '2-Screen', '4-Screen'] as const; +type ValidScene = typeof VALID_SCENES[number]; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { sceneName } = body; + + // Validate scene name + if (!sceneName || typeof sceneName !== 'string') { + return NextResponse.json( + { success: false, error: 'Scene name is required' }, + { status: 400 } + ); + } + + if (!VALID_SCENES.includes(sceneName as ValidScene)) { + return NextResponse.json( + { + success: false, + error: 'Invalid scene name', + validScenes: VALID_SCENES + }, + { status: 400 } + ); + } + + try { + const obsClient = await getOBSClient(); + + // Switch to the requested scene + await obsClient.call('SetCurrentProgramScene', { sceneName }); + + console.log(`Successfully switched to scene: ${sceneName}`); + + return NextResponse.json({ + success: true, + data: { sceneName }, + message: `Switched to ${sceneName} layout` + }); + } catch (obsError) { + console.error('OBS WebSocket error:', obsError); + return NextResponse.json( + { + success: false, + error: 'Failed to switch scene in OBS', + details: obsError instanceof Error ? obsError.message : 'Unknown error' + }, + { status: 500 } + ); + } + } catch (error) { + console.error('Error switching scene:', error); + return NextResponse.json( + { + success: false, + error: 'Invalid request format' + }, + { status: 400 } + ); + } +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index ee8dcf2..7094cd5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,24 +5,20 @@ import Dropdown from '@/components/Dropdown'; import { useToast } from '@/lib/useToast'; import { ToastContainer } from '@/components/Toast'; import { useActiveSourceLookup, useDebounce, PerformanceMonitor } from '@/lib/performance'; +import { SCREEN_POSITIONS } from '@/lib/constants'; import { StreamWithTeam } from '@/types'; -type ScreenType = 'large' | 'left' | 'right' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; +type ScreenType = typeof SCREEN_POSITIONS[number]; export default function Home() { const [streams, setStreams] = useState([]); - const [activeSources, setActiveSources] = useState>({ - large: null, - left: null, - right: null, - topLeft: null, - topRight: null, - bottomLeft: null, - bottomRight: null, - }); + const [activeSources, setActiveSources] = useState>( + Object.fromEntries(SCREEN_POSITIONS.map(screen => [screen, null])) as Record + ); const [isLoading, setIsLoading] = useState(true); const [openDropdown, setOpenDropdown] = useState(null); + const [currentScene, setCurrentScene] = useState(null); const { toasts, removeToast, showSuccess, showError } = useToast(); // Memoized active source lookup for performance @@ -63,22 +59,27 @@ export default function Home() { const fetchData = useCallback(async () => { const endTimer = PerformanceMonitor.startTimer('fetchData'); try { - // Fetch streams and active sources in parallel - const [streamsRes, activeRes] = await Promise.all([ + // Fetch streams, active sources, and current scene in parallel + const [streamsRes, activeRes, sceneRes] = await Promise.all([ fetch('/api/streams'), - fetch('/api/getActive') + fetch('/api/getActive'), + fetch('/api/getCurrentScene') ]); - const [streamsData, activeData] = await Promise.all([ + const [streamsData, activeData, sceneData] = await Promise.all([ streamsRes.json(), - activeRes.json() + activeRes.json(), + sceneRes.json() ]); // Handle both old and new API response formats const streams = streamsData.success ? streamsData.data : streamsData; const activeSources = activeData.success ? activeData.data : activeData; + const sceneName = sceneData.success ? sceneData.data.sceneName : null; + setStreams(streams); setActiveSources(activeSources); + setCurrentScene(sceneName); } catch (error) { console.error('Error fetching data:', error); showError('Failed to Load Data', 'Could not fetch streams. Please refresh the page.'); @@ -114,14 +115,48 @@ export default function Home() { setOpenDropdown((prev) => (prev === screen ? null : screen)); }, []); + const handleSceneSwitch = useCallback(async (sceneName: string) => { + try { + const response = await fetch('/api/setScene', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sceneName }), + }); + + const result = await response.json(); + + if (result.success) { + // Update local state immediately for responsive UI + setCurrentScene(sceneName); + showSuccess('Scene Changed', `Switched to ${sceneName} layout`); + } else { + throw new Error(result.error || 'Failed to switch scene'); + } + } catch (error) { + console.error('Error switching scene:', error); + showError('Scene Switch Failed', error instanceof Error ? error.message : 'Could not switch scene. Please try again.'); + } + }, [showSuccess, showError]); + // Memoized corner displays to prevent re-renders const cornerDisplays = useMemo(() => [ - { screen: 'topLeft' as const, label: 'Top Left' }, - { screen: 'topRight' as const, label: 'Top Right' }, - { screen: 'bottomLeft' as const, label: 'Bottom Left' }, - { screen: 'bottomRight' as const, label: 'Bottom Right' }, + { screen: 'top_left' as const, label: 'Top Left' }, + { screen: 'top_right' as const, label: 'Top Right' }, + { screen: 'bottom_left' as const, label: 'Bottom Left' }, + { screen: 'bottom_right' as const, label: 'Bottom Right' }, ], []); + // Transform and sort streams for dropdown display + const dropdownStreams = useMemo(() => { + return streams + .map(stream => ({ + id: stream.id, + name: `${stream.team_name} - ${stream.name}`, + originalStream: stream + })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [streams]); + if (isLoading) { return (
@@ -145,10 +180,23 @@ export default function Home() { {/* Main Screen */}
-

Primary Display

+
+

Primary Display

+ +
handleSetActive('large', id)} label="Select Primary Stream..." @@ -160,12 +208,25 @@ export default function Home() { {/* Side Displays */}
-

Side Displays

+
+

Side Displays

+ +

Left Display

handleSetActive('left', id)} label="Select Left Stream..." @@ -176,7 +237,7 @@ export default function Home() {

Right Display

handleSetActive('right', id)} label="Select Right Stream..." @@ -189,13 +250,26 @@ export default function Home() { {/* Corner Displays */}
-

Corner Displays

+
+

Corner Displays

+ +
{cornerDisplays.map(({ screen, label }) => (

{label}

handleSetActive(screen, id)} label="Select Stream..."