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
This commit is contained in:
Decobus 2025-07-22 21:11:30 +03:00
commit 7f475680ae
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