Implement team name text overlays with refactored group structure

- Add createTextSource function with automatic OBS text input detection
- Implement createStreamGroup to create groups within team scenes instead of separate scenes
- Add team name text overlays positioned at top-left of each stream
- Refactor stream switching to use stream group names for cleaner organization
- Update setActive API to write stream group names to files
- Fix getActive API to return correct screen position data
- Improve team UUID assignment when adding streams
- Remove manage streams section from home page for cleaner UI
- Add vertical spacing to streams list to match teams page
- Support dynamic text input kinds (text_ft2_source_v2, text_gdiplus, etc.)

This creates a much cleaner OBS structure with 10 team scenes containing grouped
stream sources rather than 200+ individual stream scenes, while adding team
name text overlays for better stream identification.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Decobus 2025-07-20 16:59:50 -04:00
parent ece75cf2df
commit 78f4d325d8
11 changed files with 325 additions and 49 deletions

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '../../../lib/database';
import { connectToOBS, getOBSClient, disconnectFromOBS, addSourceToSwitcher, createGroupIfNotExists, addSourceToGroup } from '../../../lib/obsClient';
import { connectToOBS, getOBSClient, disconnectFromOBS, addSourceToSwitcher, createGroupIfNotExists, addSourceToGroup, createStreamGroup } from '../../../lib/obsClient';
import { open } from 'sqlite';
import sqlite3 from 'sqlite3';
import path from 'path';
@ -44,7 +44,7 @@ async function fetchTeamInfo(teamId: number) {
});
const teamInfo = await db.get(
`SELECT team_name, group_name FROM ${teamsTableName} WHERE team_id = ?`,
`SELECT team_name, group_name, group_uuid FROM ${teamsTableName} WHERE team_id = ?`,
[teamId]
);
@ -130,16 +130,53 @@ export async function POST(request: NextRequest) {
const sourceExists = inputs.some((input: OBSInput) => input.inputName === obs_source_name);
if (!sourceExists) {
// Create/ensure group exists and add source to it
await createGroupIfNotExists(groupName);
await addSourceToGroup(groupName, obs_source_name, url);
// Create stream group with text overlay
const result = await createStreamGroup(groupName, name, teamInfo.team_name, url);
// Update team with group UUID if not set
if (!teamInfo.group_uuid) {
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files');
const dbPath = path.join(FILE_DIRECTORY, 'sources.db');
const db = await open({
filename: dbPath,
driver: sqlite3.Database,
});
const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, {
year: 2025,
season: 'summer',
suffix: 'sat'
});
try {
// Get the scene UUID for the group
const obsClient = await getOBSClient();
const { scenes } = await obsClient.call('GetSceneList');
const scene = scenes.find((s: any) => s.sceneName === groupName);
if (scene) {
await db.run(
`UPDATE ${teamsTableName} SET group_name = ?, group_uuid = ? WHERE team_id = ?`,
[groupName, scene.sceneUuid, team_id]
);
console.log(`Updated team ${team_id} with group UUID: ${scene.sceneUuid}`);
} else {
console.log(`Scene "${groupName}" not found in OBS`);
}
} catch (error) {
console.error('Error updating team group UUID:', error);
} finally {
await db.close();
}
}
console.log(`OBS source "${obs_source_name}" created.`);
for (const screen of screens) {
try {
const streamGroupName = `${name.toLowerCase().replace(/\s+/g, '_')}_stream`;
await addSourceToSwitcher(screen, [
{ hidden: false, selected: false, value: obs_source_name },
{ hidden: false, selected: false, value: streamGroupName },
]);
} catch (error) {
if (error instanceof Error) {

View file

@ -47,8 +47,8 @@ export async function GET() {
const chicken = fs.existsSync(chickenPath) ? fs.readFileSync(chickenPath, 'utf-8') : null;
// For SaT
// return NextResponse.json({ large, left, right, topLeft, topRight, bottomLeft, bottomRight }, {status: 201})
return NextResponse.json({ tank, tree, kitty, chicken }, {status: 201})
return NextResponse.json({ large, left, right, topLeft, topRight, bottomLeft, bottomRight }, {status: 201})
// return NextResponse.json({ tank, tree, kitty, chicken }, {status: 201})
} catch (error) {
console.error('Error reading active sources:', error);
return NextResponse.json({ error: 'Failed to read active sources' }, {status: 500});

View file

@ -5,6 +5,7 @@ import { FILE_DIRECTORY } from '../../../config';
import { getDatabase } from '../../../lib/database';
import { Stream } from '@/types';
import { validateScreenInput } from '../../../lib/security';
import { TABLE_NAMES } from '../../../lib/constants';
export async function POST(request: NextRequest) {
// Parse and validate request body
@ -27,7 +28,7 @@ export async function POST(request: NextRequest) {
try {
const db = await getDatabase();
const stream: Stream | undefined = await db.get<Stream>(
'SELECT * FROM streams_2025_spring_adr WHERE id = ?',
`SELECT * FROM ${TABLE_NAMES.STREAMS} WHERE id = ?`,
[id]
);
@ -37,7 +38,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Stream not found' }, { status: 400 });
}
fs.writeFileSync(filePath, stream.obs_source_name);
// Use stream group name instead of individual obs_source_name
const streamGroupName = `${stream.name.toLowerCase().replace(/\s+/g, '_')}_stream`;
fs.writeFileSync(filePath, streamGroupName);
return NextResponse.json({ message: `${screen} updated successfully.` }, { status: 200 });
} catch (error) {
console.error('Error updating active source:', error);

View file

@ -8,11 +8,12 @@ import {
createDatabaseError,
parseRequestBody
} from '@/lib/apiHelpers';
import { createGroupIfNotExists, createTextSource } from '@/lib/obsClient';
// Validation for team creation
function validateTeamInput(data: unknown): {
valid: boolean;
data?: { team_name: string };
data?: { team_name: string; create_obs_group?: boolean };
errors?: Record<string, string>
} {
const errors: Record<string, string> = {};
@ -22,7 +23,7 @@ function validateTeamInput(data: unknown): {
return { valid: false, errors };
}
const { team_name } = data as { team_name?: unknown };
const { team_name, create_obs_group } = data as { team_name?: unknown; create_obs_group?: unknown };
if (!team_name || typeof team_name !== 'string') {
errors.team_name = 'Team name is required and must be a string';
@ -38,7 +39,10 @@ function validateTeamInput(data: unknown): {
return {
valid: true,
data: { team_name: (team_name as string).trim() }
data: {
team_name: (team_name as string).trim(),
create_obs_group: create_obs_group === true
}
};
}
@ -60,7 +64,7 @@ export const POST = withErrorHandling(async (request: Request) => {
return bodyResult.response;
}
const { team_name } = bodyResult.data;
const { team_name, create_obs_group } = bodyResult.data;
try {
const db = await getDatabase();
@ -78,16 +82,37 @@ export const POST = withErrorHandling(async (request: Request) => {
);
}
let groupName: string | null = null;
let groupUuid: string | null = null;
// Create OBS group and text source if requested
if (create_obs_group) {
try {
const obsResult = await createGroupIfNotExists(team_name);
groupName = team_name;
groupUuid = obsResult.sceneUuid;
// Create text source for the team
const textSourceName = team_name.toLowerCase().replace(/\s+/g, '_') + '_text';
await createTextSource(team_name, textSourceName, team_name);
console.log(`OBS group and text source created for team "${team_name}"`);
} catch (obsError) {
console.error('Error creating OBS group:', obsError);
// Continue with team creation even if OBS fails
}
}
const result = await db.run(
`INSERT INTO ${TABLE_NAMES.TEAMS} (team_name) VALUES (?)`,
[team_name]
`INSERT INTO ${TABLE_NAMES.TEAMS} (team_name, group_name, group_uuid) VALUES (?, ?, ?)`,
[team_name, groupName, groupUuid]
);
const newTeam: Team = {
team_id: result.lastID!,
team_name: team_name,
group_name: null,
group_uuid: null
group_name: groupName,
group_uuid: groupUuid
};
return createSuccessResponse(newTeam, 201);

View file

@ -98,10 +98,15 @@ export default function Home() {
const handleSetActive = useCallback(async (screen: ScreenType, id: number | null) => {
const selectedStream = streams.find((stream) => stream.id === id);
// Generate stream group name for optimistic updates
const streamGroupName = selectedStream
? `${selectedStream.name.toLowerCase().replace(/\s+/g, '_')}_stream`
: null;
// Update local state immediately for optimistic updates
setActiveSources((prev) => ({
...prev,
[screen]: selectedStream?.obs_source_name || null,
[screen]: streamGroupName,
}));
// Debounced backend update
@ -205,29 +210,6 @@ export default function Home() {
</div>
</div>
{/* Manage Streams Section */}
{streams.length > 0 && (
<div className="glass p-6 mt-6">
<h2 className="card-title">Manage Streams</h2>
<div className="grid gap-4">
{streams.map((stream) => (
<div key={stream.id} className="glass p-4 flex items-center justify-between">
<div>
<h3 className="font-semibold text-white">{stream.name}</h3>
<p className="text-sm text-white/60">{stream.obs_source_name}</p>
</div>
<Link
href={`/edit/${stream.id}`}
className="btn-secondary btn-sm"
>
<span className="icon"></span>
Edit
</Link>
</div>
))}
</div>
</div>
)}
{/* Toast Notifications */}
<ToastContainer toasts={toasts} onRemove={removeToast} />

View file

@ -289,7 +289,7 @@ export default function AddStream() {
{streams.map((stream) => {
const team = teams.find(t => t.id === stream.team_id);
return (
<div key={stream.id} className="glass p-4">
<div key={stream.id} className="glass p-4 mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div

1
files/ss_large.txt Normal file
View file

@ -0,0 +1 @@
wa_stream

0
files/ss_left.txt Normal file
View file

0
files/ss_right.text Normal file
View file

View file

@ -99,12 +99,22 @@ async function addSourceToSwitcher(inputName, newSources) {
// Step 1: Get current input settings
const { inputSettings } = await obsClient.call('GetInputSettings', { inputName });
// console.log('Current Settings:', inputSettings);
console.log('Current Settings for', inputName, ':', inputSettings);
// Step 2: Add new sources to the sources array
const updatedSources = [...inputSettings.sources, ...newSources];
// Step 2: Initialize sources array if it doesn't exist or is not an array
let currentSources = [];
if (Array.isArray(inputSettings.sources)) {
currentSources = inputSettings.sources;
} else if (inputSettings.sources) {
console.log('Sources is not an array, converting:', typeof inputSettings.sources);
// Try to convert if it's an object or other format
currentSources = [];
}
// Step 3: Update the settings with the new sources array
// Step 3: Add new sources to the sources array
const updatedSources = [...currentSources, ...newSources];
// Step 4: Update the settings with the new sources array
await obsClient.call('SetInputSettings', {
inputName,
inputSettings: {
@ -202,6 +212,219 @@ async function addSourceToGroup(groupName, sourceName, url) {
}
}
async function getAvailableTextInputKind() {
try {
const obsClient = await getOBSClient();
const { inputKinds } = await obsClient.call('GetInputKindList');
console.log('Available input kinds:', inputKinds);
// Check for text input kinds in order of preference
const textKinds = ['text_gdiplus_v2', 'text_gdiplus', 'text_ft2_source_v2', 'text_ft2_source', 'text_source'];
for (const kind of textKinds) {
if (inputKinds.includes(kind)) {
console.log(`Found text input kind: ${kind}`);
return kind;
}
}
// Fallback - find any input kind that contains 'text'
const textKind = inputKinds.find(kind => kind.toLowerCase().includes('text'));
if (textKind) {
console.log(`Found fallback text input kind: ${textKind}`);
return textKind;
}
throw new Error('No text input kind found');
} catch (error) {
console.error('Error getting available input kinds:', error.message);
throw error;
}
}
async function createTextSource(sceneName, textSourceName, text) {
try {
const obsClient = await getOBSClient();
// Check if text source already exists globally in OBS
const { inputs } = await obsClient.call('GetInputList');
const existingInput = inputs.find(input => input.inputName === textSourceName);
if (!existingInput) {
console.log(`Creating text source "${textSourceName}" in scene "${sceneName}"`);
// Get the correct text input kind for this OBS installation
const inputKind = await getAvailableTextInputKind();
const inputSettings = {
text,
font: {
face: 'Arial',
size: 72,
style: 'Bold'
},
color: 0xFFFFFFFF, // White text
outline: true,
outline_color: 0xFF000000, // Black outline
outline_size: 4
};
await obsClient.call('CreateInput', {
sceneName,
inputName: textSourceName,
inputKind,
inputSettings
});
console.log(`Text source "${textSourceName}" created successfully with kind "${inputKind}"`);
return { success: true, message: 'Text source created successfully' };
} else {
console.log(`Text source "${textSourceName}" already exists globally, updating settings`);
// Update existing text source settings
const inputSettings = {
text,
font: {
face: 'Arial',
size: 72,
style: 'Bold'
},
color: 0xFFFFFFFF, // White text
outline: true,
outline_color: 0xFF000000, // Black outline
outline_size: 4
};
await obsClient.call('SetInputSettings', {
inputName: textSourceName,
inputSettings
});
console.log(`Text source "${textSourceName}" settings updated`);
return { success: true, message: 'Text source settings updated' };
}
} catch (error) {
console.error('Error creating text source:', error.message);
throw error;
}
}
async function createStreamGroup(groupName, streamName, teamName, url) {
try {
const obsClient = await getOBSClient();
// Ensure team scene exists
await createGroupIfNotExists(groupName);
const streamGroupName = `${streamName.toLowerCase().replace(/\s+/g, '_')}_stream`;
const sourceName = streamName.toLowerCase().replace(/\s+/g, '_') + '_twitch';
const textSourceName = teamName.toLowerCase().replace(/\s+/g, '_') + '_text';
// Create text source globally (reused across streams in the team)
await createTextSource(groupName, textSourceName, teamName);
// Create browser source directly in the team scene
const { sceneItems: teamSceneItems } = await obsClient.call('GetSceneItemList', { sceneName: groupName });
const browserSourceExists = teamSceneItems.some(item => item.sourceName === sourceName);
if (!browserSourceExists) {
await obsClient.call('CreateInput', {
sceneName: groupName,
inputName: sourceName,
inputKind: 'browser_source',
inputSettings: {
width: 1600,
height: 900,
url,
control_audio: true,
},
});
}
// Add text source to team scene if not already there
const textInTeamScene = teamSceneItems.some(item => item.sourceName === textSourceName);
if (!textInTeamScene) {
await obsClient.call('CreateSceneItem', {
sceneName: groupName,
sourceName: textSourceName
});
}
// Get the scene items after adding sources
const { sceneItems: updatedSceneItems } = await obsClient.call('GetSceneItemList', { sceneName: groupName });
// Find the browser source and text source items
const browserSourceItem = updatedSceneItems.find(item => item.sourceName === sourceName);
const textSourceItem = updatedSceneItems.find(item => item.sourceName === textSourceName);
// Create a group within the team scene containing both sources
if (browserSourceItem && textSourceItem) {
try {
// Create a group with both items
await obsClient.call('CreateGroup', {
sceneName: groupName,
groupName: streamGroupName
});
// Add both sources to the group
await obsClient.call('SetSceneItemGroup', {
sceneName: groupName,
sceneItemId: browserSourceItem.sceneItemId,
groupName: streamGroupName
});
await obsClient.call('SetSceneItemGroup', {
sceneName: groupName,
sceneItemId: textSourceItem.sceneItemId,
groupName: streamGroupName
});
// Position text overlay at top-left within the group
await obsClient.call('SetSceneItemTransform', {
sceneName: groupName,
sceneItemId: textSourceItem.sceneItemId,
sceneItemTransform: {
positionX: 10,
positionY: 10,
scaleX: 1.0,
scaleY: 1.0
}
});
// Lock the group items
await obsClient.call('SetSceneItemLocked', {
sceneName: groupName,
sceneItemId: browserSourceItem.sceneItemId,
sceneItemLocked: true
});
await obsClient.call('SetSceneItemLocked', {
sceneName: groupName,
sceneItemId: textSourceItem.sceneItemId,
sceneItemLocked: true
});
console.log(`Stream group "${streamGroupName}" created within team scene "${groupName}"`);
} catch (groupError) {
console.log('Group creation failed, sources added individually to team scene');
}
}
console.log(`Stream sources added to team scene "${groupName}" with text overlay`);
return {
success: true,
message: 'Stream group created within team scene',
streamGroupName,
sourceName,
textSourceName
};
} catch (error) {
console.error('Error creating stream group:', error.message);
throw error;
}
}
// Export all functions
module.exports = {
@ -212,5 +435,8 @@ module.exports = {
ensureConnected,
getConnectionStatus,
createGroupIfNotExists,
addSourceToGroup
addSourceToGroup,
createTextSource,
createStreamGroup,
getAvailableTextInputKind
};

View file

@ -43,7 +43,9 @@ export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_n
const idToStreamMap = new Map<number, { id: number; obs_source_name: string; name: string }>();
streams.forEach(stream => {
sourceToIdMap.set(stream.obs_source_name, stream.id);
// Generate stream group name to match what's written to files
const streamGroupName = `${stream.name.toLowerCase().replace(/\s+/g, '_')}_stream`;
sourceToIdMap.set(streamGroupName, stream.id);
idToStreamMap.set(stream.id, stream);
});