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/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/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( 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..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; @@ -241,6 +251,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 { @@ -248,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 { @@ -261,7 +293,7 @@ body { .dropdown-item.active { background: rgba(38, 139, 210, 0.3); - color: #fdf6e3; + color: var(--solarized-base3); } /* Icon Sizes */ @@ -356,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; } @@ -366,4 +414,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: var(--solarized-base1); +} + +.collapsible-icon.open { + transform: rotate(90deg); +} + +.collapsible-title { + flex: 1; + font-size: 18px; + font-weight: 600; + color: var(--solarized-base3); + text-align: left; + margin: 0; +} + +.collapsible-count { + background: rgba(38, 139, 210, 0.2); + color: var(--solarized-blue); + 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 7094cd5..3ed4197 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,86 @@ 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; + const isPreview = studioModeEnabled && currentPreviewScene === sceneName; + + if (studioModeEnabled) { + if (isProgram && isPreview) { + return { + isActive: true, + text: `Program & Preview: ${sceneName}`, + className: 'active', + showTransition: false + }; + } else if (isProgram) { + return { + isActive: true, + text: `Program: ${sceneName}`, + className: 'active', + showTransition: false + }; + } else if (isPreview) { + return { + isActive: true, + text: `Preview: ${sceneName}`, + className: 'btn-scene-preview', + showTransition: true + }; + } else { + return { + isActive: false, + text: `Set Preview: ${sceneName}`, + className: '', + showTransition: false + }; + } + } else { + // Normal mode + if (isProgram) { + return { + isActive: true, + text: `Active: ${sceneName}`, + className: 'active', + showTransition: false + }; + } else { + return { + isActive: false, + text: `Switch to ${sceneName}`, + className: '', + showTransition: false + }; + } + } + }, [currentScene, currentPreviewScene, studioModeEnabled]); + // Memoized corner displays to prevent re-renders const cornerDisplays = useMemo(() => [ { screen: 'top_left' as const, label: 'Top Left' }, @@ -182,17 +282,29 @@ export default function Home() {

Primary Display

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

Side Displays

- +
+ {(() => { + const buttonState = getSceneButtonState('2-Screen'); + return ( + <> + + {buttonState.showTransition && ( + + )} + + ); + })()} +
@@ -252,17 +376,29 @@ export default function Home() {

Corner Displays

- +
+ {(() => { + 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..fb1896c 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,172 @@ 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 +479,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 edbcb26..d7c4f77 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; }; @@ -83,8 +85,8 @@ export default function Footer() {

OBS Studio

-
-
+
+

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

@@ -96,17 +98,22 @@ export default function Footer() {
{obsStatus.host}:{obsStatus.port}
{obsStatus.hasPassword &&
🔒 Authenticated
} - {/* Streaming/Recording Status */} + {/* 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'}
)} @@ -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: 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'); } 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