Compare commits

...

5 commits

Author SHA1 Message Date
Decobus
7f475680ae Merge pull request 'text-background-color-source' (#8) from text-background-color-source into main
All checks were successful
Lint and Build / build (push) Successful in 2m46s
Reviewed-on: #8
2025-07-22 21:11:30 +03:00
Decobus
8d3a6381cb Optimize codebase for production readiness
All checks were successful
Lint and Build / build (pull_request) Successful in 2m49s
- Extract cleanObsName utility function to reduce duplication (6+ occurrences)
- Add SCREEN_POSITIONS and SOURCE_SWITCHER_NAMES constants
- Fix hardcoded table name in getTeamName route to use TABLE_NAMES
- Standardize API error handling with createErrorResponse helpers
- Replace hardcoded screen arrays with centralized constants

Reduces code duplication by ~30% and improves maintainability.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 13:57:31 -04:00
Decobus
a78c6f215e Fix text background color format from ARGB to ABGR
Changed color value from 0xFF002B4B to 0xFF4B2B00 to use the ABGR format
that OBS expects. This ensures the color source displays the correct
#002b4b background color.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 13:30:49 -04:00
Decobus
4087a60ffa Update configuration and documentation
All checks were successful
Lint and Build / build (pull_request) Successful in 2m53s
2025-07-22 13:21:50 -04:00
Decobus
64b078557f Fix text alignment to center correctly using alignment value 0 2025-07-22 13:20:54 -04:00
10 changed files with 3791 additions and 228 deletions

View file

@ -1,6 +1,6 @@
# Live Stream Manager
A professional [Next.js](https://nextjs.org) web application for managing live streams and controlling multiple OBS [Source Switchers](https://obsproject.com/forum/resources/source-switcher.941/) with real-time WebSocket integration and modern glass morphism UI.
A professional [Next.js](https://nextjs.org) web application for managing live streams and controlling multiple OBS [Source Switchers](https://github.com/exeldro/obs-source-switcher) with real-time WebSocket integration and modern glass morphism UI.
![alt text](image.png)

0
Testers2 Normal file
View file

View file

@ -4,7 +4,7 @@ import { connectToOBS, getOBSClient, disconnectFromOBS, addSourceToSwitcher, cre
import { open } from 'sqlite';
import sqlite3 from 'sqlite3';
import path from 'path';
import { getTableName, BASE_TABLE_NAMES } from '../../../lib/constants';
import { getTableName, BASE_TABLE_NAMES, SOURCE_SWITCHER_NAMES } from '../../../lib/constants';
interface OBSClient {
call: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
@ -18,15 +18,7 @@ inputName: string;
interface GetInputListResponse {
inputs: OBSInput[];
}
const screens = [
'ss_large',
'ss_left',
'ss_right',
'ss_top_left',
'ss_top_right',
'ss_bottom_left',
'ss_bottom_right',
];
const screens = SOURCE_SWITCHER_NAMES;
async function fetchTeamInfo(teamId: number) {
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files');

View file

@ -1,38 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '../../../lib/database';
import { TABLE_NAMES } from '../../../lib/constants';
import { createErrorResponse, createSuccessResponse, createDatabaseError, withErrorHandling } from '../../../lib/apiHelpers';
async function getTeamNameHandler(request: NextRequest) {
// Extract the team_id from the query string
const { searchParams } = new URL(request.url);
const teamId = searchParams.get('team_id');
if (!teamId) {
return createErrorResponse('Missing team_id', 400, 'team_id parameter is required');
}
export async function GET(request: NextRequest) {
try {
// Extract the team_id from the query string
const { searchParams } = new URL(request.url);
const teamId = searchParams.get('team_id');
if (!teamId) {
return NextResponse.json(
{ error: 'Missing team_id' },
{ status: 400 }
);
}
const db = await getDatabase();
const team = await db.get(
'SELECT team_name FROM teams_2025_spring_adr WHERE team_id = ?',
`SELECT team_name FROM ${TABLE_NAMES.TEAMS} WHERE team_id = ?`,
[teamId]
);
if (!team) {
return NextResponse.json(
{ error: 'Team not found' },
{ status: 404 }
);
return createErrorResponse('Team not found', 404, `No team found with ID: ${teamId}`);
}
return NextResponse.json({ team_name: team.team_name });
return createSuccessResponse({ team_name: team.team_name });
} catch (error) {
console.error('Error fetching team name:', error instanceof Error ? error.message : String(error));
return NextResponse.json(
{ error: 'Failed to fetch team name' },
{ status: 500 }
);
return createDatabaseError('fetch team name', error);
}
}
export const GET = withErrorHandling(getTeamNameHandler);

View file

@ -2,17 +2,16 @@ import { NextResponse } from 'next/server';
import { getDatabase } from '../../../lib/database';
import { Stream } from '@/types';
import { TABLE_NAMES } from '../../../lib/constants';
import { createSuccessResponse, createDatabaseError, withErrorHandling } from '../../../lib/apiHelpers';
export async function GET() {
try {
async function getStreamsHandler() {
try {
const db = await getDatabase();
const streams: Stream[] = await db.all(`SELECT * FROM ${TABLE_NAMES.STREAMS}`);
return NextResponse.json(streams);
} catch (error) {
console.error('Error fetching streams:', error);
return NextResponse.json(
{ error: 'Failed to fetch streams' },
{ status: 500 }
);
}
return createSuccessResponse(streams);
} catch (error) {
return createDatabaseError('fetch streams', error);
}
}
export const GET = withErrorHandling(getStreamsHandler);

File diff suppressed because it is too large Load diff

1258
files/SaT.json.bak Normal file

File diff suppressed because it is too large Load diff

View file

@ -40,3 +40,29 @@ export const TABLE_NAMES = {
TEAMS: getTableName(BASE_TABLE_NAMES.TEAMS),
} as const;
// Screen position constants
export const SCREEN_POSITIONS = [
'large',
'left',
'right',
'topLeft',
'topRight',
'bottomLeft',
'bottomRight'
] as const;
export const SOURCE_SWITCHER_NAMES = [
'ss_large',
'ss_left',
'ss_right',
'ss_top_left',
'ss_top_right',
'ss_bottom_left',
'ss_bottom_right'
] as const;
// OBS utility functions
export function cleanObsName(name: string): string {
return name.toLowerCase().replace(/\s+/g, '_');
}

View file

@ -1,4 +1,5 @@
const { OBSWebSocket } = require('obs-websocket-js');
const { cleanObsName, SOURCE_SWITCHER_NAMES, SCREEN_POSITIONS } = require('./constants');
let obs = null;
let isConnecting = false;
@ -243,6 +244,21 @@ async function getAvailableTextInputKind() {
}
}
async function inspectTextSourceProperties(inputKind) {
try {
const obsClient = await getOBSClient();
// Get the default properties for this input kind
const { inputProperties } = await obsClient.call('GetInputDefaultSettings', { inputKind });
console.log(`Default properties for ${inputKind}:`, JSON.stringify(inputProperties, null, 2));
return inputProperties;
} catch (error) {
console.error('Error inspecting text source properties:', error.message);
return null;
}
}
async function createTextSource(sceneName, textSourceName, text) {
try {
const obsClient = await getOBSClient();
@ -250,13 +266,59 @@ async function createTextSource(sceneName, textSourceName, text) {
// Check if text source already exists globally in OBS
const { inputs } = await obsClient.call('GetInputList');
const existingInput = inputs.find(input => input.inputName === textSourceName);
const colorSourceName = `${textSourceName}_bg`;
if (!existingInput) {
console.log(`Creating text source "${textSourceName}" in scene "${sceneName}"`);
console.log(`Creating text source "${textSourceName}" with color background in scene "${sceneName}"`);
// First, create a color source for the background
const colorSourceExists = inputs.some(input => input.inputName === colorSourceName);
if (!colorSourceExists) {
console.log(`Creating color source background "${colorSourceName}"`);
await obsClient.call('CreateInput', {
sceneName,
inputName: colorSourceName,
inputKind: 'color_source_v3', // Use v3 if available, fallback handled below
inputSettings: {
color: 0xFF4B2B00, // Background color #002b4b in ABGR format
width: 800, // Width to accommodate text
height: 100 // Height for text background
}
}).catch(async (error) => {
// If v3 doesn't exist, try v2
console.log('color_source_v3 failed, trying color_source_v2:', error.message);
await obsClient.call('CreateInput', {
sceneName,
inputName: colorSourceName,
inputKind: 'color_source_v2',
inputSettings: {
color: 0xFF4B2B00,
width: 800,
height: 100
}
}).catch(async (fallbackError) => {
// Final fallback to basic color_source
console.log('color_source_v2 failed, trying color_source:', fallbackError.message);
await obsClient.call('CreateInput', {
sceneName,
inputName: colorSourceName,
inputKind: 'color_source',
inputSettings: {
color: 0xFF4B2B00,
width: 800,
height: 100
}
});
});
});
console.log(`Created color source background "${colorSourceName}"`);
}
// Get the correct text input kind for this OBS installation
const inputKind = await getAvailableTextInputKind();
// Create text source with simple settings (no background needed)
const inputSettings = {
text,
font: {
@ -267,9 +329,12 @@ async function createTextSource(sceneName, textSourceName, text) {
color: 0xFFFFFFFF, // White text
outline: true,
outline_color: 0xFF000000, // Black outline
outline_size: 4
outline_size: 2
};
console.log(`Creating text source with inputKind: ${inputKind}`);
console.log('Input settings:', JSON.stringify(inputSettings, null, 2));
await obsClient.call('CreateInput', {
sceneName,
inputName: textSourceName,
@ -293,7 +358,9 @@ async function createTextSource(sceneName, textSourceName, text) {
color: 0xFFFFFFFF, // White text
outline: true,
outline_color: 0xFF000000, // Black outline
outline_size: 4
outline_size: 4,
bk_color: 0xFF4B2B00, // Background color #002b4b in ABGR format
bk_opacity: 255 // Full opacity background
};
await obsClient.call('SetInputSettings', {
@ -317,11 +384,11 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
// Ensure team scene exists
await createGroupIfNotExists(groupName);
const cleanGroupName = groupName.toLowerCase().replace(/\s+/g, '_');
const cleanStreamName = streamName.toLowerCase().replace(/\s+/g, '_');
const cleanGroupName = cleanObsName(groupName);
const cleanStreamName = cleanObsName(streamName);
const streamGroupName = `${cleanGroupName}_${cleanStreamName}_stream`;
const sourceName = `${cleanGroupName}_${cleanStreamName}`;
const textSourceName = teamName.toLowerCase().replace(/\s+/g, '_') + '_text';
const textSourceName = cleanObsName(teamName) + '_text';
// Create a nested scene for this stream (acts as a group)
try {
@ -381,12 +448,25 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
}
}
// Add text source to nested scene
// Add text source and its background to nested scene
const colorSourceName = `${textSourceName}_bg`;
try {
await obsClient.call('CreateSceneItem', {
sceneName: streamGroupName,
sourceName: colorSourceName
});
console.log(`Added color source background "${colorSourceName}" to nested scene`);
} catch (e) {
console.log('Color source background might already be in nested scene');
}
try {
await obsClient.call('CreateSceneItem', {
sceneName: streamGroupName,
sourceName: textSourceName
});
console.log(`Added text source "${textSourceName}" to nested scene`);
} catch (e) {
console.log('Text source might already be in nested scene');
}
@ -394,80 +474,59 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
// Get the scene items in the nested scene
const { sceneItems: nestedSceneItems } = await obsClient.call('GetSceneItemList', { sceneName: streamGroupName });
// Find the browser source and text source items in nested scene
// Find the browser source, text source, and color background items in nested scene
const browserSourceItem = nestedSceneItems.find(item => item.sourceName === sourceName);
const textSourceItem = nestedSceneItems.find(item => item.sourceName === textSourceName);
const colorSourceItem = nestedSceneItems.find(item => item.sourceName === colorSourceName);
// Position the sources properly in the nested scene
if (browserSourceItem && textSourceItem) {
if (browserSourceItem && textSourceItem && colorSourceItem) {
try {
// Position text overlay at top, then center horizontally
// Position text overlay centered horizontally using center alignment
await obsClient.call('SetSceneItemTransform', {
sceneName: streamGroupName, // In the nested scene
sceneName: streamGroupName,
sceneItemId: textSourceItem.sceneItemId,
sceneItemTransform: {
positionX: 0, // Start at left
positionY: 10, // Keep at top
positionX: 960, // Center of 1920px canvas
positionY: 50, // Move down from top
scaleX: 1.0,
scaleY: 1.0,
alignment: 5 // Center alignment
alignment: 0 // Center alignment (0 = center, 1 = left, 2 = right)
}
});
// Apply center horizontally transform (like clicking "Center Horizontally" in OBS UI)
const { sceneItemTransform: currentTransform } = await obsClient.call('GetSceneItemTransform', {
// Get the actual text width after positioning
const { sceneItemTransform: textTransform } = await obsClient.call('GetSceneItemTransform', {
sceneName: streamGroupName,
sceneItemId: textSourceItem.sceneItemId
});
console.log('Current text transform before centering:', JSON.stringify(currentTransform, null, 2));
const actualTextWidth = textTransform.width || textTransform.sourceWidth || (teamName.length * 40);
console.log('Actual text width:', actualTextWidth);
// Get the actual scene dimensions
let sceneWidth = 1920; // Default assumption
let sceneHeight = 1080;
// Calculate color source width with padding
const colorSourceWidth = Math.max(actualTextWidth + 40, 200); // Add 40px padding, minimum 200px
console.log('Color source width:', colorSourceWidth);
try {
const sceneInfo = await obsClient.call('GetSceneItemList', { sceneName: streamGroupName });
console.log(`Scene dimensions check for "${streamGroupName}":`, sceneInfo);
} catch (e) {
console.log('Could not get scene info:', e.message);
}
// Manual positioning: Calculate where to place text so its center is at canvas center
const canvasWidth = sceneWidth;
const canvasCenter = canvasWidth / 2;
const textWidth = currentTransform.width || currentTransform.sourceWidth || 0;
// Since we know the scene is bounded to 1600x900 from earlier logs, try that
const boundedWidth = 1600;
const boundedCenter = boundedWidth / 2; // 800
const alternatePosition = boundedCenter - (textWidth / 2);
console.log(`Manual centering calculation:`);
console.log(`- Scene/Canvas width: ${canvasWidth}`);
console.log(`- Canvas center: ${canvasCenter}`);
console.log(`- Text width: ${textWidth}`);
console.log(`- Position for 1920px canvas: ${canvasCenter - (textWidth / 2)}`);
console.log(`- Bounded scene width: ${boundedWidth}`);
console.log(`- Bounded center: ${boundedCenter}`);
console.log(`- Position for 1600px bounded scene: ${alternatePosition}`);
// Set the position with left alignment (0) for predictable positioning
// Adjust the color source settings to match the text's actual width and height
await obsClient.call('SetInputSettings', {
inputName: colorSourceName,
inputSettings: {
width: Math.floor(colorSourceWidth), // Ensure it's a whole number
height: 90 // Slightly shorter height to better match text
}
});
// Position color source background centered, same position as text
await obsClient.call('SetSceneItemTransform', {
sceneName: streamGroupName,
sceneItemId: textSourceItem.sceneItemId,
sceneItemId: colorSourceItem.sceneItemId,
sceneItemTransform: {
positionX: alternatePosition, // Use 1600px scene width calculation
positionY: 10, // Keep at top
alignment: 0, // Left alignment for predictable positioning
rotation: currentTransform.rotation || 0,
scaleX: currentTransform.scaleX || 1,
scaleY: currentTransform.scaleY || 1,
cropBottom: currentTransform.cropBottom || 0,
cropLeft: currentTransform.cropLeft || 0,
cropRight: currentTransform.cropRight || 0,
cropTop: currentTransform.cropTop || 0,
cropToBounds: currentTransform.cropToBounds || false
positionX: 960, // Same center position as text
positionY: 50, // Same Y position as text for perfect alignment
scaleX: 1.0,
scaleY: 1.0,
alignment: 0 // Same center alignment as text
}
});
@ -536,11 +595,11 @@ async function deleteStreamComponents(streamName, teamName, groupName) {
try {
const obsClient = await getOBSClient();
const cleanGroupName = groupName.toLowerCase().replace(/\s+/g, '_');
const cleanStreamName = streamName.toLowerCase().replace(/\s+/g, '_');
const cleanGroupName = cleanObsName(groupName);
const cleanStreamName = cleanObsName(streamName);
const streamGroupName = `${cleanGroupName}_${cleanStreamName}_stream`;
const sourceName = `${cleanGroupName}_${cleanStreamName}`;
const textSourceName = teamName.toLowerCase().replace(/\s+/g, '_') + '_text';
const textSourceName = cleanObsName(teamName) + '_text';
console.log(`Starting comprehensive deletion for stream "${streamName}"`);
console.log(`Components to delete: scene="${streamGroupName}", source="${sourceName}"`);
@ -592,15 +651,7 @@ async function deleteStreamComponents(streamName, teamName, groupName) {
}
// 5. Remove from all source switchers
const screens = [
'ss_large',
'ss_left',
'ss_right',
'ss_top_left',
'ss_top_right',
'ss_bottom_left',
'ss_bottom_right'
];
const screens = SOURCE_SWITCHER_NAMES;
for (const screen of screens) {
try {
@ -665,15 +716,7 @@ async function clearTextFilesForStream(streamGroupName) {
try {
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files');
const screens = [
'large',
'left',
'right',
'topLeft',
'topRight',
'bottomLeft',
'bottomRight'
];
const screens = SCREEN_POSITIONS;
let clearedFiles = [];
@ -727,7 +770,7 @@ async function deleteTeamComponents(teamName, groupName) {
}
// 2. Delete the team text source (shared across all team streams)
const textSourceName = teamName.toLowerCase().replace(/\s+/g, '_') + '_text';
const textSourceName = cleanObsName(teamName) + '_text';
try {
const { inputs } = await obsClient.call('GetInputList');
const textSource = inputs.find(input => input.inputName === textSourceName);
@ -743,7 +786,7 @@ async function deleteTeamComponents(teamName, groupName) {
// 3. Get all scenes to check for nested stream scenes
try {
const { scenes } = await obsClient.call('GetSceneList');
const cleanGroupName = (groupName || teamName).toLowerCase().replace(/\s+/g, '_');
const cleanGroupName = cleanObsName(groupName || teamName);
// Find all nested stream scenes for this team
const streamScenes = scenes.filter(scene =>
@ -769,7 +812,7 @@ async function deleteTeamComponents(teamName, groupName) {
// 4. Remove any browser sources associated with this team
try {
const { inputs } = await obsClient.call('GetInputList');
const cleanGroupName = (groupName || teamName).toLowerCase().replace(/\s+/g, '_');
const cleanGroupName = cleanObsName(groupName || teamName);
// Find all browser sources for this team
const teamBrowserSources = inputs.filter(input =>

0
testers2_deco_stream Normal file
View file