Add comprehensive studio mode support and stream organization

- 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 <noreply@anthropic.com>
This commit is contained in:
Decobus 2025-07-25 21:29:23 -04:00
parent 07028b0792
commit 3bad71cb26
8 changed files with 603 additions and 116 deletions

View file

@ -63,7 +63,7 @@ function generateOBSSourceName(teamSceneName: string, streamName: string): strin
} }
export async function POST(request: NextRequest) { 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 // Parse and validate request body
try { try {
@ -78,6 +78,7 @@ export async function POST(request: NextRequest) {
} }
({ name, url, team_id } = validation.data!); ({ name, url, team_id } = validation.data!);
lockSources = body.lockSources !== false; // Default to true if not specified
} catch { } catch {
return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 });
@ -125,7 +126,7 @@ export async function POST(request: NextRequest) {
if (!sourceExists) { if (!sourceExists) {
// Create stream group with text overlay // 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 // Update team with group UUID if not set
if (!teamInfo.group_uuid) { if (!teamInfo.group_uuid) {

View file

@ -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 }
);
}
}

View file

@ -241,6 +241,28 @@ body {
position: absolute; position: absolute;
transform: translateZ(0); transform: translateZ(0);
will-change: transform; 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 { .dropdown-item {
@ -366,4 +388,79 @@ body {
.p-8 { .p-8 {
padding: 32px; 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;
} }

View file

@ -158,6 +158,31 @@ export default function Home() {
} }
}, [showSuccess, showError]); }, [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 // Helper function to get scene button state and styling
const getSceneButtonState = useCallback((sceneName: string) => { const getSceneButtonState = useCallback((sceneName: string) => {
const isProgram = currentScene === sceneName; const isProgram = currentScene === sceneName;
@ -168,25 +193,29 @@ export default function Home() {
return { return {
isActive: true, isActive: true,
text: `Program & Preview: ${sceneName}`, 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) { } else if (isProgram) {
return { return {
isActive: true, isActive: true,
text: `Program: ${sceneName}`, 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) { } else if (isPreview) {
return { return {
isActive: true, isActive: true,
text: `Preview: ${sceneName}`, 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 { } else {
return { return {
isActive: false, isActive: false,
text: `Set Preview: ${sceneName}`, 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 { } else {
@ -195,13 +224,15 @@ export default function Home() {
return { return {
isActive: true, isActive: true,
text: `Active: ${sceneName}`, 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 { } else {
return { return {
isActive: false, isActive: false,
text: `Switch to ${sceneName}`, 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() {
<div className="glass p-6 mb-6"> <div className="glass p-6 mb-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="card-title mb-0">Primary Display</h2> <h2 className="card-title mb-0">Primary Display</h2>
{(() => { <div className="flex">
const buttonState = getSceneButtonState('1-Screen'); {(() => {
return ( const buttonState = getSceneButtonState('1-Screen');
<button return (
onClick={() => handleSceneSwitch('1-Screen')} <>
className={`btn ${buttonState.isActive ? 'active' : ''}`} <button
style={{ background: buttonState.background }} onClick={() => handleSceneSwitch('1-Screen')}
> className={`btn ${buttonState.isActive ? 'active' : ''}`}
{buttonState.text} style={{ background: buttonState.background }}
</button> >
); {buttonState.text}
})()} </button>
{buttonState.showTransition && (
<button
onClick={handleTransition}
className="btn"
style={{
background: 'linear-gradient(135deg, var(--solarized-red), var(--solarized-magenta))',
minWidth: '120px',
marginLeft: '12px'
}}
>
Go Live
</button>
)}
</>
);
})()}
</div>
</div> </div>
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<Dropdown <Dropdown
@ -280,18 +328,35 @@ export default function Home() {
<div className="glass p-6 mb-6"> <div className="glass p-6 mb-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="card-title mb-0">Side Displays</h2> <h2 className="card-title mb-0">Side Displays</h2>
{(() => { <div className="flex">
const buttonState = getSceneButtonState('2-Screen'); {(() => {
return ( const buttonState = getSceneButtonState('2-Screen');
<button return (
onClick={() => handleSceneSwitch('2-Screen')} <>
className={`btn ${buttonState.isActive ? 'active' : ''}`} <button
style={{ background: buttonState.background }} onClick={() => handleSceneSwitch('2-Screen')}
> className={`btn ${buttonState.isActive ? 'active' : ''}`}
{buttonState.text} style={{ background: buttonState.background }}
</button> >
); {buttonState.text}
})()} </button>
{buttonState.showTransition && (
<button
onClick={handleTransition}
className="btn"
style={{
background: 'linear-gradient(135deg, var(--solarized-red), var(--solarized-magenta))',
minWidth: '120px',
marginLeft: '12px'
}}
>
Go Live
</button>
)}
</>
);
})()}
</div>
</div> </div>
<div className="grid-2"> <div className="grid-2">
<div> <div>
@ -323,18 +388,35 @@ export default function Home() {
<div className="glass p-6"> <div className="glass p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="card-title mb-0">Corner Displays</h2> <h2 className="card-title mb-0">Corner Displays</h2>
{(() => { <div className="flex">
const buttonState = getSceneButtonState('4-Screen'); {(() => {
return ( const buttonState = getSceneButtonState('4-Screen');
<button return (
onClick={() => handleSceneSwitch('4-Screen')} <>
className={`btn ${buttonState.isActive ? 'active' : ''}`} <button
style={{ background: buttonState.background }} onClick={() => handleSceneSwitch('4-Screen')}
> className={`btn ${buttonState.isActive ? 'active' : ''}`}
{buttonState.text} style={{ background: buttonState.background }}
</button> >
); {buttonState.text}
})()} </button>
{buttonState.showTransition && (
<button
onClick={handleTransition}
className="btn"
style={{
background: 'linear-gradient(135deg, var(--solarized-red), var(--solarized-magenta))',
minWidth: '120px',
marginLeft: '12px'
}}
>
Go Live
</button>
)}
</>
);
})()}
</div>
</div> </div>
<div className="grid-4"> <div className="grid-4">
{cornerDisplays.map(({ screen, label }) => ( {cornerDisplays.map(({ screen, label }) => (

View file

@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import Dropdown from '@/components/Dropdown'; import Dropdown from '@/components/Dropdown';
import CollapsibleGroup from '@/components/CollapsibleGroup';
import { Team } from '@/types'; import { Team } from '@/types';
import { useToast } from '@/lib/useToast'; import { useToast } from '@/lib/useToast';
import { ToastContainer } from '@/components/Toast'; import { ToastContainer } from '@/components/Toast';
@ -14,6 +15,174 @@ interface Stream {
team_id: number; 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<Set<number>>(new Set());
const [useCustomExpanded, setUseCustomExpanded] = useState(false);
// Group streams by team
const streamsByTeam = useMemo(() => {
const grouped = new Map<number, Stream[]>();
// 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 (
<div>
{streamsByTeam.length > 0 && (
<div className="flex justify-end gap-2 mb-4">
<button
className="btn btn-secondary btn-sm"
onClick={handleExpandAll}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
Expand All
</button>
<button
className="btn btn-secondary btn-sm"
onClick={handleCollapseAll}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
Collapse All
</button>
</div>
)}
<div className="space-y-4">
{streamsByTeam.map(({ teamId, teamName, streams: teamStreams }) => (
<CollapsibleGroup
key={teamId}
title={teamName}
itemCount={teamStreams.length}
defaultOpen={teamStreams.length <= 10}
isOpen={useCustomExpanded ? expandedGroups.has(teamId) : undefined}
onToggle={() => handleToggleGroup(teamId)}
>
<div className="space-y-4">
{teamStreams.map((stream) => (
<div key={stream.id} className="glass p-4 mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div
className="bg-gradient-to-br from-green-500 to-blue-600 rounded-lg flex items-center justify-center text-white font-bold flex-shrink-0"
style={{
width: '64px',
height: '64px',
fontSize: '24px',
marginRight: '16px'
}}
>
{stream.name.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="font-semibold text-white">{stream.name}</div>
<div className="text-sm text-white/60">OBS: {stream.obs_source_name}</div>
</div>
</div>
<div className="text-right space-y-2">
<div className="text-sm text-white/40">ID: {stream.id}</div>
<div className="flex justify-end">
<a
href={stream.url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary text-sm"
style={{ marginRight: '8px' }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
View Stream
</a>
<button
onClick={() => onDelete(stream)}
className="btn btn-danger text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</button>
</div>
</div>
</div>
</div>
))}
</div>
</CollapsibleGroup>
))}
</div>
</div>
);
}
export default function AddStream() { export default function AddStream() {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
@ -312,61 +481,11 @@ export default function AddStream() {
<div className="text-white/40 text-sm">Create your first stream above!</div> <div className="text-white/40 text-sm">Create your first stream above!</div>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <StreamsByTeam
{streams.map((stream) => { streams={streams}
const team = teams.find(t => t.id === stream.team_id); teams={teams}
return ( onDelete={(stream) => setDeleteConfirm({ id: stream.id, name: stream.name })}
<div key={stream.id} className="glass p-4 mb-4"> />
<div className="flex items-center justify-between">
<div className="flex items-center">
<div
className="bg-gradient-to-br from-green-500 to-blue-600 rounded-lg flex items-center justify-center text-white font-bold flex-shrink-0"
style={{
width: '64px',
height: '64px',
fontSize: '24px',
marginRight: '16px'
}}
>
{stream.name.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="font-semibold text-white">{stream.name}</div>
<div className="text-sm text-white/60">OBS: {stream.obs_source_name}</div>
<div className="text-sm text-white/60">Team: {team?.name || 'Unknown'}</div>
</div>
</div>
<div className="text-right space-y-2">
<div className="text-sm text-white/40">ID: {stream.id}</div>
<div className="flex justify-end">
<a
href={stream.url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary text-sm"
style={{ marginRight: '8px' }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
View Stream
</a>
<button
onClick={() => setDeleteConfirm({ id: stream.id, name: stream.name })}
className="btn btn-danger text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</button>
</div>
</div>
</div>
</div>
);
})}
</div>
)} )}
</div> </div>

View file

@ -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 (
<div className="collapsible-group">
<button
className="collapsible-header"
onClick={handleToggle}
aria-expanded={isOpen}
>
<div className="collapsible-header-content">
<svg
className={`collapsible-icon ${isOpen ? 'open' : ''}`}
width="20"
height="20"
viewBox="0 0 20 20"
fill="currentColor"
>
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
<h3 className="collapsible-title">{title}</h3>
<span className="collapsible-count">{itemCount}</span>
</div>
</button>
<div className={`collapsible-content ${isOpen ? 'open' : ''}`}>
<div className="collapsible-content-inner">
{children}
</div>
</div>
</div>
);
}

View file

@ -85,8 +85,8 @@ export default function Footer() {
<div> <div>
<div className="mb-4"> <div className="mb-4">
<h3 className="font-semibold mb-2">OBS Studio</h3> <h3 className="font-semibold mb-2">OBS Studio</h3>
<div className="flex items-center gap-2"> <div className="flex items-center">
<div className={`status-dot ${obsStatus?.connected ? 'connected' : 'disconnected'}`}></div> <div className={`status-dot ${obsStatus?.connected ? 'connected' : 'disconnected'}`} style={{marginRight: '4px'}}></div>
<p className="text-sm opacity-60"> <p className="text-sm opacity-60">
{obsStatus?.connected ? 'Connected' : 'Disconnected'} {obsStatus?.connected ? 'Connected' : 'Disconnected'}
</p> </p>
@ -100,20 +100,20 @@ export default function Footer() {
{/* Streaming/Recording/Studio Mode Status */} {/* Streaming/Recording/Studio Mode Status */}
{obsStatus.connected && ( {obsStatus.connected && (
<div className="flex gap-4 mt-4"> <div className="flex flex-wrap gap-6 mt-4">
<div className={`flex items-center gap-2 ${obsStatus.streaming ? 'text-red-400' : 'opacity-60'}`}> <div className={`flex items-center ${obsStatus.streaming ? 'text-red-400' : 'opacity-60'}`}>
<div className={`status-dot ${obsStatus.streaming ? 'streaming' : 'idle'}`} style={{width: '8px', height: '8px'}}></div> <div className={`status-dot ${obsStatus.streaming ? 'streaming' : 'idle'}`} style={{width: '10px', height: '10px', marginRight: '4px'}}></div>
<span className="text-sm">{obsStatus.streaming ? 'LIVE' : 'OFFLINE'}</span> <span className="text-sm font-medium" style={{marginRight: '8px'}}>{obsStatus.streaming ? 'LIVE' : 'OFFLINE'}</span>
</div> </div>
<div className={`flex items-center gap-2 ${obsStatus.recording ? 'text-red-400' : 'opacity-60'}`}> <div className={`flex items-center ${obsStatus.recording ? 'text-red-400' : 'opacity-60'}`}>
<div className={`status-dot ${obsStatus.recording ? 'streaming' : 'idle'}`} style={{width: '8px', height: '8px'}}></div> <div className={`status-dot ${obsStatus.recording ? 'streaming' : 'idle'}`} style={{width: '10px', height: '10px', marginRight: '4px'}}></div>
<span className="text-sm">{obsStatus.recording ? 'REC' : 'IDLE'}</span> <span className="text-sm font-medium" style={{marginRight: '8px'}}>{obsStatus.recording ? 'REC' : 'IDLE'}</span>
</div> </div>
<div className={`flex items-center gap-2 ${obsStatus.studioModeEnabled ? 'text-yellow-400' : 'opacity-60'}`}> <div className={`flex items-center ${obsStatus.studioModeEnabled ? 'text-yellow-400' : 'opacity-60'}`}>
<div className={`status-dot ${obsStatus.studioModeEnabled ? 'connected' : 'idle'}`} style={{width: '8px', height: '8px'}}></div> <div className={`status-dot ${obsStatus.studioModeEnabled ? 'connected' : 'idle'}`} style={{width: '10px', height: '10px', marginRight: '4px'}}></div>
<span className="text-sm">{obsStatus.studioModeEnabled ? 'STUDIO' : 'DIRECT'}</span> <span className="text-sm font-medium">{obsStatus.studioModeEnabled ? 'STUDIO' : 'DIRECT'}</span>
</div> </div>
</div> </div>
)} )}

View file

@ -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 { try {
const obsClient = await getOBSClient(); const obsClient = await getOBSClient();
@ -443,14 +443,48 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
} catch (muteError) { } catch (muteError) {
console.error(`Failed to mute browser source audio for "${sourceName}":`, muteError.message); 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 { } else {
// Add existing source to nested scene // Add existing source to nested scene
await obsClient.call('CreateSceneItem', { const { sceneItemId } = await obsClient.call('CreateSceneItem', {
sceneName: streamGroupName, sceneName: streamGroupName,
sourceName: sourceName sourceName: sourceName
}); });
console.log(`Added existing browser source "${sourceName}" to nested scene`); 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 // Ensure existing browser source has audio control enabled and correct URL
try { try {
await obsClient.call('SetInputSettings', { await obsClient.call('SetInputSettings', {
@ -482,21 +516,49 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
const colorSourceName = `${textSourceName}_bg`; const colorSourceName = `${textSourceName}_bg`;
try { try {
await obsClient.call('CreateSceneItem', { const { sceneItemId: colorItemId } = await obsClient.call('CreateSceneItem', {
sceneName: streamGroupName, sceneName: streamGroupName,
sourceName: colorSourceName sourceName: colorSourceName
}); });
console.log(`Added color source background "${colorSourceName}" to nested scene`); 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) { } catch (error) {
console.log('Color source background might already be in nested scene'); console.log('Color source background might already be in nested scene');
} }
try { try {
await obsClient.call('CreateSceneItem', { const { sceneItemId: textItemId } = await obsClient.call('CreateSceneItem', {
sceneName: streamGroupName, sceneName: streamGroupName,
sourceName: textSourceName sourceName: textSourceName
}); });
console.log(`Added text source "${textSourceName}" to nested scene`); 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) { } catch (error) {
console.log('Text source might already be in nested scene'); console.log('Text source might already be in nested scene');
} }