Compare commits
No commits in common. "4f9e6d2097f845f5bf17e2ab593f818cd5b2c5f2" and "fdf781dcf9959eebadc9489452e812878f836b8a" have entirely different histories.
4f9e6d2097
...
fdf781dcf9
11 changed files with 160 additions and 761 deletions
|
@ -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, lockSources: boolean;
|
let name: string, url: string, team_id: number, obs_source_name: string;
|
||||||
|
|
||||||
// Parse and validate request body
|
// Parse and validate request body
|
||||||
try {
|
try {
|
||||||
|
@ -78,7 +78,6 @@ 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 });
|
||||||
|
@ -126,7 +125,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, lockSources);
|
await createStreamGroup(groupName, name, teamInfo.team_name, url);
|
||||||
|
|
||||||
// Update team with group UUID if not set
|
// Update team with group UUID if not set
|
||||||
if (!teamInfo.group_uuid) {
|
if (!teamInfo.group_uuid) {
|
||||||
|
|
|
@ -19,11 +19,9 @@ export async function GET() {
|
||||||
obsWebSocketVersion: string;
|
obsWebSocketVersion: string;
|
||||||
};
|
};
|
||||||
currentScene?: string;
|
currentScene?: string;
|
||||||
currentPreviewScene?: string;
|
|
||||||
sceneCount?: number;
|
sceneCount?: number;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
recording?: boolean;
|
recording?: boolean;
|
||||||
studioModeEnabled?: boolean;
|
|
||||||
error?: string;
|
error?: string;
|
||||||
} = {
|
} = {
|
||||||
host: OBS_HOST,
|
host: OBS_HOST,
|
||||||
|
@ -60,31 +58,15 @@ export async function GET() {
|
||||||
// Get recording status
|
// Get recording status
|
||||||
const recordStatus = await obs.call('GetRecordStatus');
|
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.connected = true;
|
||||||
connectionStatus.version = {
|
connectionStatus.version = {
|
||||||
obsVersion: versionInfo.obsVersion,
|
obsVersion: versionInfo.obsVersion,
|
||||||
obsWebSocketVersion: versionInfo.obsWebSocketVersion
|
obsWebSocketVersion: versionInfo.obsWebSocketVersion
|
||||||
};
|
};
|
||||||
connectionStatus.currentScene = currentSceneInfo.sceneName;
|
connectionStatus.currentScene = currentSceneInfo.sceneName;
|
||||||
connectionStatus.currentPreviewScene = currentPreviewScene;
|
|
||||||
connectionStatus.sceneCount = sceneList.scenes.length;
|
connectionStatus.sceneCount = sceneList.scenes.length;
|
||||||
connectionStatus.streaming = streamStatus.outputActive;
|
connectionStatus.streaming = streamStatus.outputActive;
|
||||||
connectionStatus.recording = recordStatus.outputActive;
|
connectionStatus.recording = recordStatus.outputActive;
|
||||||
connectionStatus.studioModeEnabled = studioModeStatus.studioModeEnabled;
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
connectionStatus.error = err instanceof Error ? err.message : 'Unknown error occurred';
|
connectionStatus.error = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||||
|
|
|
@ -32,30 +32,16 @@ export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const obsClient = await getOBSClient();
|
const obsClient = await getOBSClient();
|
||||||
|
|
||||||
// Check if studio mode is active
|
// Switch to the requested scene
|
||||||
const { studioModeEnabled } = await obsClient.call('GetStudioModeEnabled');
|
await obsClient.call('SetCurrentProgramScene', { sceneName });
|
||||||
|
|
||||||
if (studioModeEnabled) {
|
console.log(`Successfully switched to scene: ${sceneName}`);
|
||||||
// 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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: { sceneName, studioMode: true },
|
data: { sceneName },
|
||||||
message: `Preview set to ${sceneName} layout (Studio Mode) - ready to transition`
|
message: `Switched to ${sceneName} layout`
|
||||||
});
|
});
|
||||||
} 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) {
|
} catch (obsError) {
|
||||||
console.error('OBS WebSocket error:', obsError);
|
console.error('OBS WebSocket error:', obsError);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
|
@ -1,62 +0,0 @@
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
177
app/globals.css
177
app/globals.css
|
@ -20,14 +20,6 @@
|
||||||
--solarized-red: #dc322f;
|
--solarized-red: #dc322f;
|
||||||
--solarized-magenta: #d33682;
|
--solarized-magenta: #d33682;
|
||||||
--solarized-violet: #6c71c4;
|
--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 */
|
/* Modern CSS Foundation */
|
||||||
|
@ -42,8 +34,8 @@ html {
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--gradient-body);
|
background: linear-gradient(135deg, #002b36 0%, #073642 50%, #002b36 100%);
|
||||||
color: var(--solarized-base1);
|
color: #93a1a1;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
@ -83,8 +75,8 @@ body {
|
||||||
|
|
||||||
/* Modern Button System */
|
/* Modern Button System */
|
||||||
.btn {
|
.btn {
|
||||||
background: var(--gradient-primary);
|
background: linear-gradient(135deg, #268bd2, #2aa198);
|
||||||
color: var(--solarized-base3);
|
color: #fdf6e3;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
@ -101,10 +93,9 @@ body {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.active,
|
.btn.active {
|
||||||
.btn-success {
|
background: linear-gradient(135deg, #859900, #b58900);
|
||||||
background: var(--gradient-active);
|
color: #fdf6e3;
|
||||||
color: var(--solarized-base3);
|
|
||||||
box-shadow: 0 0 0 3px rgba(133, 153, 0, 0.5);
|
box-shadow: 0 0 0 3px rgba(133, 153, 0, 0.5);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
@ -126,7 +117,7 @@ body {
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: rgba(88, 110, 117, 0.3);
|
background: rgba(88, 110, 117, 0.3);
|
||||||
border: 1px solid rgba(131, 148, 150, 0.4);
|
border: 1px solid rgba(131, 148, 150, 0.4);
|
||||||
color: var(--solarized-base1);
|
color: #93a1a1;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -136,14 +127,25 @@ body {
|
||||||
box-shadow: 0 6px 20px rgba(88, 110, 117, 0.3);
|
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 */
|
/* Danger Button */
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: var(--gradient-danger);
|
background: linear-gradient(135deg, #dc322f, #cb4b16);
|
||||||
color: var(--solarized-base3);
|
color: #fdf6e3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover {
|
.btn-danger:hover {
|
||||||
background: linear-gradient(135deg, var(--solarized-orange), var(--solarized-red));
|
background: linear-gradient(135deg, #cb4b16, #dc322f);
|
||||||
box-shadow: 0 6px 20px rgba(220, 50, 47, 0.4);
|
box-shadow: 0 6px 20px rgba(220, 50, 47, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -167,18 +169,6 @@ body {
|
||||||
flex-shrink: 0;
|
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 spacing fixes since Tailwind gap classes aren't working */
|
||||||
.form-row {
|
.form-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -204,7 +194,7 @@ body {
|
||||||
border: 1px solid rgba(88, 110, 117, 0.4);
|
border: 1px solid rgba(88, 110, 117, 0.4);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
color: var(--solarized-base1);
|
color: #93a1a1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
@ -215,7 +205,7 @@ body {
|
||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--solarized-blue);
|
border-color: #268bd2;
|
||||||
box-shadow: 0 0 0 3px rgba(38, 139, 210, 0.2);
|
box-shadow: 0 0 0 3px rgba(38, 139, 210, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -225,7 +215,7 @@ body {
|
||||||
border: 1px solid rgba(88, 110, 117, 0.4);
|
border: 1px solid rgba(88, 110, 117, 0.4);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
color: var(--solarized-base1);
|
color: #93a1a1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@ -251,28 +241,6 @@ 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 {
|
||||||
|
@ -280,7 +248,7 @@ body {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
border-bottom: 1px solid rgba(88, 110, 117, 0.2);
|
border-bottom: 1px solid rgba(88, 110, 117, 0.2);
|
||||||
color: var(--solarized-base1);
|
color: #93a1a1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item:last-child {
|
.dropdown-item:last-child {
|
||||||
|
@ -293,7 +261,7 @@ body {
|
||||||
|
|
||||||
.dropdown-item.active {
|
.dropdown-item.active {
|
||||||
background: rgba(38, 139, 210, 0.3);
|
background: rgba(38, 139, 210, 0.3);
|
||||||
color: var(--solarized-base3);
|
color: #fdf6e3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Icon Sizes */
|
/* Icon Sizes */
|
||||||
|
@ -388,22 +356,6 @@ body {
|
||||||
margin-bottom: 32px;
|
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 {
|
.p-4 {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
@ -415,78 +367,3 @@ 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: 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;
|
|
||||||
}
|
|
218
app/page.tsx
218
app/page.tsx
|
@ -19,8 +19,6 @@ export default function Home() {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
||||||
const [currentScene, setCurrentScene] = useState<string | null>(null);
|
const [currentScene, setCurrentScene] = useState<string | null>(null);
|
||||||
const [currentPreviewScene, setCurrentPreviewScene] = useState<string | null>(null);
|
|
||||||
const [studioModeEnabled, setStudioModeEnabled] = useState<boolean>(false);
|
|
||||||
const { toasts, removeToast, showSuccess, showError } = useToast();
|
const { toasts, removeToast, showSuccess, showError } = useToast();
|
||||||
|
|
||||||
// Memoized active source lookup for performance
|
// Memoized active source lookup for performance
|
||||||
|
@ -61,19 +59,17 @@ export default function Home() {
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
const endTimer = PerformanceMonitor.startTimer('fetchData');
|
const endTimer = PerformanceMonitor.startTimer('fetchData');
|
||||||
try {
|
try {
|
||||||
// Fetch streams, active sources, current scene, and OBS status in parallel
|
// Fetch streams, active sources, and current scene in parallel
|
||||||
const [streamsRes, activeRes, sceneRes, obsStatusRes] = await Promise.all([
|
const [streamsRes, activeRes, sceneRes] = await Promise.all([
|
||||||
fetch('/api/streams'),
|
fetch('/api/streams'),
|
||||||
fetch('/api/getActive'),
|
fetch('/api/getActive'),
|
||||||
fetch('/api/getCurrentScene'),
|
fetch('/api/getCurrentScene')
|
||||||
fetch('/api/obsStatus')
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [streamsData, activeData, sceneData, obsStatusData] = await Promise.all([
|
const [streamsData, activeData, sceneData] = await Promise.all([
|
||||||
streamsRes.json(),
|
streamsRes.json(),
|
||||||
activeRes.json(),
|
activeRes.json(),
|
||||||
sceneRes.json(),
|
sceneRes.json()
|
||||||
obsStatusRes.json()
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Handle both old and new API response formats
|
// Handle both old and new API response formats
|
||||||
|
@ -84,15 +80,6 @@ export default function Home() {
|
||||||
setStreams(streams);
|
setStreams(streams);
|
||||||
setActiveSources(activeSources);
|
setActiveSources(activeSources);
|
||||||
setCurrentScene(sceneName);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching data:', error);
|
console.error('Error fetching data:', error);
|
||||||
showError('Failed to Load Data', 'Could not fetch streams. Please refresh the page.');
|
showError('Failed to Load Data', 'Could not fetch streams. Please refresh the page.');
|
||||||
|
@ -139,16 +126,9 @@ export default function Home() {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Update local state based on studio mode
|
// Update local state immediately for responsive UI
|
||||||
if (result.data.studioMode) {
|
setCurrentScene(sceneName);
|
||||||
// In studio mode, update preview scene
|
showSuccess('Scene Changed', `Switched to ${sceneName} layout`);
|
||||||
setCurrentPreviewScene(sceneName);
|
|
||||||
showSuccess('Preview Set', result.message);
|
|
||||||
} else {
|
|
||||||
// In normal mode, update program scene
|
|
||||||
setCurrentScene(sceneName);
|
|
||||||
showSuccess('Scene Changed', `Switched to ${sceneName} layout`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || 'Failed to switch scene');
|
throw new Error(result.error || 'Failed to switch scene');
|
||||||
}
|
}
|
||||||
|
@ -158,86 +138,6 @@ 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
|
|
||||||
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
|
// Memoized corner displays to prevent re-renders
|
||||||
const cornerDisplays = useMemo(() => [
|
const cornerDisplays = useMemo(() => [
|
||||||
{ screen: 'top_left' as const, label: 'Top Left' },
|
{ screen: 'top_left' as const, label: 'Top Left' },
|
||||||
|
@ -282,29 +182,17 @@ 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">
|
<button
|
||||||
{(() => {
|
onClick={() => handleSceneSwitch('1-Screen')}
|
||||||
const buttonState = getSceneButtonState('1-Screen');
|
className={`btn ${currentScene === '1-Screen' ? 'active' : ''}`}
|
||||||
return (
|
style={{
|
||||||
<>
|
background: currentScene === '1-Screen'
|
||||||
<button
|
? 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))'
|
||||||
onClick={() => handleSceneSwitch('1-Screen')}
|
: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))'
|
||||||
className={`btn ${buttonState.className}`}
|
}}
|
||||||
>
|
>
|
||||||
{buttonState.text}
|
{currentScene === '1-Screen' ? 'Active: 1-Screen' : 'Switch to 1-Screen'}
|
||||||
</button>
|
</button>
|
||||||
{buttonState.showTransition && (
|
|
||||||
<button
|
|
||||||
onClick={handleTransition}
|
|
||||||
className="btn btn-scene-transition ml-3"
|
|
||||||
>
|
|
||||||
Go Live
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-md mx-auto">
|
<div className="max-w-md mx-auto">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
@ -322,29 +210,17 @@ 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">
|
<button
|
||||||
{(() => {
|
onClick={() => handleSceneSwitch('2-Screen')}
|
||||||
const buttonState = getSceneButtonState('2-Screen');
|
className={`btn ${currentScene === '2-Screen' ? 'active' : ''}`}
|
||||||
return (
|
style={{
|
||||||
<>
|
background: currentScene === '2-Screen'
|
||||||
<button
|
? 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))'
|
||||||
onClick={() => handleSceneSwitch('2-Screen')}
|
: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))'
|
||||||
className={`btn ${buttonState.className}`}
|
}}
|
||||||
>
|
>
|
||||||
{buttonState.text}
|
{currentScene === '2-Screen' ? 'Active: 2-Screen' : 'Switch to 2-Screen'}
|
||||||
</button>
|
</button>
|
||||||
{buttonState.showTransition && (
|
|
||||||
<button
|
|
||||||
onClick={handleTransition}
|
|
||||||
className="btn btn-scene-transition ml-3"
|
|
||||||
>
|
|
||||||
Go Live
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div>
|
<div>
|
||||||
|
@ -376,29 +252,17 @@ 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">
|
<button
|
||||||
{(() => {
|
onClick={() => handleSceneSwitch('4-Screen')}
|
||||||
const buttonState = getSceneButtonState('4-Screen');
|
className={`btn ${currentScene === '4-Screen' ? 'active' : ''}`}
|
||||||
return (
|
style={{
|
||||||
<>
|
background: currentScene === '4-Screen'
|
||||||
<button
|
? 'linear-gradient(135deg, var(--solarized-green), var(--solarized-yellow))'
|
||||||
onClick={() => handleSceneSwitch('4-Screen')}
|
: 'linear-gradient(135deg, var(--solarized-blue), var(--solarized-cyan))'
|
||||||
className={`btn ${buttonState.className}`}
|
}}
|
||||||
>
|
>
|
||||||
{buttonState.text}
|
{currentScene === '4-Screen' ? 'Active: 4-Screen' : 'Switch to 4-Screen'}
|
||||||
</button>
|
</button>
|
||||||
{buttonState.showTransition && (
|
|
||||||
<button
|
|
||||||
onClick={handleTransition}
|
|
||||||
className="btn btn-scene-transition ml-3"
|
|
||||||
>
|
|
||||||
Go Live
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid-4">
|
<div className="grid-4">
|
||||||
{cornerDisplays.map(({ screen, label }) => (
|
{cornerDisplays.map(({ screen, label }) => (
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback } 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';
|
||||||
|
@ -15,172 +14,6 @@ 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 mr-4"
|
|
||||||
style={{
|
|
||||||
width: '64px',
|
|
||||||
height: '64px',
|
|
||||||
fontSize: '24px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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 mr-2"
|
|
||||||
>
|
|
||||||
<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: '',
|
||||||
|
@ -479,11 +312,61 @@ 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>
|
||||||
) : (
|
) : (
|
||||||
<StreamsByTeam
|
<div className="space-y-4">
|
||||||
streams={streams}
|
{streams.map((stream) => {
|
||||||
teams={teams}
|
const team = teams.find(t => t.id === stream.team_id);
|
||||||
onDelete={(stream) => setDeleteConfirm({ id: stream.id, name: stream.name })}
|
return (
|
||||||
/>
|
<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>
|
||||||
|
|
||||||
|
|
|
@ -1,64 +0,0 @@
|
||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -13,11 +13,9 @@ type OBSStatus = {
|
||||||
obsWebSocketVersion: string;
|
obsWebSocketVersion: string;
|
||||||
};
|
};
|
||||||
currentScene?: string;
|
currentScene?: string;
|
||||||
currentPreviewScene?: string;
|
|
||||||
sceneCount?: number;
|
sceneCount?: number;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
recording?: boolean;
|
recording?: boolean;
|
||||||
studioModeEnabled?: boolean;
|
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -85,8 +83,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">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`status-dot ${obsStatus?.connected ? 'connected' : 'disconnected'} mr-1`}></div>
|
<div className={`status-dot ${obsStatus?.connected ? 'connected' : 'disconnected'}`}></div>
|
||||||
<p className="text-sm opacity-60">
|
<p className="text-sm opacity-60">
|
||||||
{obsStatus?.connected ? 'Connected' : 'Disconnected'}
|
{obsStatus?.connected ? 'Connected' : 'Disconnected'}
|
||||||
</p>
|
</p>
|
||||||
|
@ -98,22 +96,17 @@ export default function Footer() {
|
||||||
<div>{obsStatus.host}:{obsStatus.port}</div>
|
<div>{obsStatus.host}:{obsStatus.port}</div>
|
||||||
{obsStatus.hasPassword && <div>🔒 Authenticated</div>}
|
{obsStatus.hasPassword && <div>🔒 Authenticated</div>}
|
||||||
|
|
||||||
{/* Streaming/Recording/Studio Mode Status */}
|
{/* Streaming/Recording Status */}
|
||||||
{obsStatus.connected && (
|
{obsStatus.connected && (
|
||||||
<div className="flex flex-wrap gap-6 mt-4">
|
<div className="flex gap-4 mt-4">
|
||||||
<div className={`flex items-center ${obsStatus.streaming ? 'text-red-400' : 'opacity-60'}`}>
|
<div className={`flex items-center gap-2 ${obsStatus.streaming ? 'text-red-400' : 'opacity-60'}`}>
|
||||||
<div className={`status-dot ${obsStatus.streaming ? 'streaming' : 'idle'} mr-1`} style={{width: '10px', height: '10px'}}></div>
|
<div className={`status-dot ${obsStatus.streaming ? 'streaming' : 'idle'}`} style={{width: '8px', height: '8px'}}></div>
|
||||||
<span className="text-sm font-medium mr-2">{obsStatus.streaming ? 'LIVE' : 'OFFLINE'}</span>
|
<span className="text-sm">{obsStatus.streaming ? 'LIVE' : 'OFFLINE'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex items-center ${obsStatus.recording ? 'text-red-400' : 'opacity-60'}`}>
|
<div className={`flex items-center gap-2 ${obsStatus.recording ? 'text-red-400' : 'opacity-60'}`}>
|
||||||
<div className={`status-dot ${obsStatus.recording ? 'streaming' : 'idle'} mr-1`} style={{width: '10px', height: '10px'}}></div>
|
<div className={`status-dot ${obsStatus.recording ? 'streaming' : 'idle'}`} style={{width: '8px', height: '8px'}}></div>
|
||||||
<span className="text-sm font-medium mr-2">{obsStatus.recording ? 'REC' : 'IDLE'}</span>
|
<span className="text-sm">{obsStatus.recording ? 'REC' : 'IDLE'}</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`flex items-center ${obsStatus.studioModeEnabled ? 'text-yellow-400' : 'opacity-60'}`}>
|
|
||||||
<div className={`status-dot ${obsStatus.studioModeEnabled ? 'connected' : 'idle'} mr-1`} style={{width: '10px', height: '10px'}}></div>
|
|
||||||
<span className="text-sm font-medium">{obsStatus.studioModeEnabled ? 'STUDIO' : 'DIRECT'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -145,18 +138,11 @@ export default function Footer() {
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
{obsStatus.currentScene && (
|
{obsStatus.currentScene && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{obsStatus.studioModeEnabled ? 'Program:' : 'Scene:'}</span>
|
<span>Scene:</span>
|
||||||
<span className="font-medium">{obsStatus.currentScene}</span>
|
<span className="font-medium">{obsStatus.currentScene}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{obsStatus.studioModeEnabled && obsStatus.currentPreviewScene && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Preview:</span>
|
|
||||||
<span className="font-medium text-yellow-400">{obsStatus.currentPreviewScene}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{obsStatus.sceneCount !== null && (
|
{obsStatus.sceneCount !== null && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Total Scenes:</span>
|
<span>Total Scenes:</span>
|
||||||
|
|
|
@ -363,7 +363,7 @@ async function createTextSource(sceneName, textSourceName, text) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createStreamGroup(groupName, streamName, teamName, url, lockSources = true) {
|
async function createStreamGroup(groupName, streamName, teamName, url) {
|
||||||
try {
|
try {
|
||||||
const obsClient = await getOBSClient();
|
const obsClient = await getOBSClient();
|
||||||
|
|
||||||
|
@ -443,48 +443,14 @@ async function createStreamGroup(groupName, streamName, teamName, url, lockSourc
|
||||||
} 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
|
||||||
const { sceneItemId } = await obsClient.call('CreateSceneItem', {
|
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', {
|
||||||
|
@ -516,49 +482,21 @@ async function createStreamGroup(groupName, streamName, teamName, url, lockSourc
|
||||||
const colorSourceName = `${textSourceName}_bg`;
|
const colorSourceName = `${textSourceName}_bg`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { sceneItemId: colorItemId } = await obsClient.call('CreateSceneItem', {
|
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 {
|
||||||
const { sceneItemId: textItemId } = await obsClient.call('CreateSceneItem', {
|
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');
|
||||||
}
|
}
|
||||||
|
|
10
styles/Home.module.css
Normal file
10
styles/Home.module.css
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
.linkButton {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #0070f3;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue