From 3bad71cb26296c5fb9007f81031384bab7eff581 Mon Sep 17 00:00:00 2001 From: Decobus Date: Fri, 25 Jul 2025 21:29:23 -0400 Subject: [PATCH] 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'); }