From 78f4d325d8548fad9b0baccb6c5136aedb198419 Mon Sep 17 00:00:00 2001 From: Decobus Date: Sun, 20 Jul 2025 16:59:50 -0400 Subject: [PATCH] Implement team name text overlays with refactored group structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/api/addStream/route.ts | 49 +++++++- app/api/getActive/route.ts | 4 +- app/api/setActive/route.ts | 7 +- app/api/teams/route.ts | 41 +++++-- app/page.tsx | 30 +---- app/streams/page.tsx | 2 +- files/ss_large.txt | 1 + files/ss_left.txt | 0 files/ss_right.text | 0 lib/obsClient.js | 236 ++++++++++++++++++++++++++++++++++++- lib/performance.ts | 4 +- 11 files changed, 325 insertions(+), 49 deletions(-) create mode 100644 files/ss_large.txt create mode 100644 files/ss_left.txt create mode 100644 files/ss_right.text diff --git a/app/api/addStream/route.ts b/app/api/addStream/route.ts index 20961df..783c05f 100644 --- a/app/api/addStream/route.ts +++ b/app/api/addStream/route.ts @@ -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) { diff --git a/app/api/getActive/route.ts b/app/api/getActive/route.ts index c8ff45d..a54b4b4 100644 --- a/app/api/getActive/route.ts +++ b/app/api/getActive/route.ts @@ -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}); diff --git a/app/api/setActive/route.ts b/app/api/setActive/route.ts index a2e107a..61fb3b8 100644 --- a/app/api/setActive/route.ts +++ b/app/api/setActive/route.ts @@ -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( - '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); diff --git a/app/api/teams/route.ts b/app/api/teams/route.ts index 2ccdaf0..88ec53e 100644 --- a/app/api/teams/route.ts +++ b/app/api/teams/route.ts @@ -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 } { const errors: Record = {}; @@ -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); diff --git a/app/page.tsx b/app/page.tsx index 2dc242d..37f33fd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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() { - {/* Manage Streams Section */} - {streams.length > 0 && ( -
-

Manage Streams

-
- {streams.map((stream) => ( -
-
-

{stream.name}

-

{stream.obs_source_name}

-
- - ✏️ - Edit - -
- ))} -
-
- )} {/* Toast Notifications */} diff --git a/app/streams/page.tsx b/app/streams/page.tsx index d24851b..8520c19 100644 --- a/app/streams/page.tsx +++ b/app/streams/page.tsx @@ -289,7 +289,7 @@ export default function AddStream() { {streams.map((stream) => { const team = teams.find(t => t.id === stream.team_id); return ( -
+
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 }; \ No newline at end of file diff --git a/lib/performance.ts b/lib/performance.ts index fa8a079..4355393 100644 --- a/lib/performance.ts +++ b/lib/performance.ts @@ -43,7 +43,9 @@ export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_n const idToStreamMap = new Map(); 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); });