Major enhancements to stream management and UI improvements
- Changed branding from "OBS Stream Manager" to "Live Stream Manager" throughout UI - Enhanced stream deletion with comprehensive OBS cleanup: - Removes stream's nested scene - Deletes browser source - Clears text files referencing the stream - Removes stream from all source switchers - Enhanced team deletion to clean up all OBS components: - Deletes team scene/group - Removes team text source - Deletes all associated stream scenes and sources - Clears all related text files - Fixed stream selection to use proper team-prefixed names in text files - Added StreamWithTeam type for proper team data handling - Improved browser source creation with audio controls: - Enabled "Control Audio via OBS" setting - Auto-mutes audio on creation - Attempted multiple approaches to fix text centering (still unresolved) Known issue: Text centering still positions left edge at center despite multiple attempts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
931813964f
commit
d6c9ac8d7f
9 changed files with 484 additions and 56 deletions
376
lib/obsClient.js
376
lib/obsClient.js
|
@ -351,12 +351,34 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
|
|||
},
|
||||
});
|
||||
console.log(`Created browser source "${sourceName}" in nested scene`);
|
||||
|
||||
// Mute the audio stream for the browser source
|
||||
try {
|
||||
await obsClient.call('SetInputMute', {
|
||||
inputName: sourceName,
|
||||
inputMuted: true
|
||||
});
|
||||
console.log(`Muted audio for browser source "${sourceName}"`);
|
||||
} catch (muteError) {
|
||||
console.error(`Failed to mute audio for "${sourceName}":`, muteError.message);
|
||||
}
|
||||
} else {
|
||||
// Add existing source to nested scene
|
||||
await obsClient.call('CreateSceneItem', {
|
||||
sceneName: streamGroupName,
|
||||
sourceName: sourceName
|
||||
});
|
||||
|
||||
// Ensure audio is muted for existing source too
|
||||
try {
|
||||
await obsClient.call('SetInputMute', {
|
||||
inputName: sourceName,
|
||||
inputMuted: true
|
||||
});
|
||||
console.log(`Ensured audio is muted for existing browser source "${sourceName}"`);
|
||||
} catch (muteError) {
|
||||
console.error(`Failed to mute audio for existing source "${sourceName}":`, muteError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Add text source to nested scene
|
||||
|
@ -379,18 +401,83 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
|
|||
// Position the sources properly in the nested scene
|
||||
if (browserSourceItem && textSourceItem) {
|
||||
try {
|
||||
// Position text overlay at top-left of the browser source
|
||||
// Position text overlay at top, then center horizontally
|
||||
await obsClient.call('SetSceneItemTransform', {
|
||||
sceneName: streamGroupName, // In the nested scene
|
||||
sceneItemId: textSourceItem.sceneItemId,
|
||||
sceneItemTransform: {
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
positionX: 0, // Start at left
|
||||
positionY: 10, // Keep at top
|
||||
scaleX: 1.0,
|
||||
scaleY: 1.0
|
||||
scaleY: 1.0,
|
||||
alignment: 5 // Center alignment
|
||||
}
|
||||
});
|
||||
|
||||
// Apply center horizontally transform (like clicking "Center Horizontally" in OBS UI)
|
||||
const { sceneItemTransform: currentTransform } = await obsClient.call('GetSceneItemTransform', {
|
||||
sceneName: streamGroupName,
|
||||
sceneItemId: textSourceItem.sceneItemId
|
||||
});
|
||||
|
||||
console.log('Current text transform before centering:', JSON.stringify(currentTransform, null, 2));
|
||||
|
||||
// Get the actual scene dimensions
|
||||
let sceneWidth = 1920; // Default assumption
|
||||
let sceneHeight = 1080;
|
||||
|
||||
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
|
||||
await obsClient.call('SetSceneItemTransform', {
|
||||
sceneName: streamGroupName,
|
||||
sceneItemId: textSourceItem.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
|
||||
}
|
||||
});
|
||||
|
||||
// Log the final transform to verify
|
||||
const { sceneItemTransform: finalTransform } = await obsClient.call('GetSceneItemTransform', {
|
||||
sceneName: streamGroupName,
|
||||
sceneItemId: textSourceItem.sceneItemId
|
||||
});
|
||||
console.log('Final text transform after centering:', JSON.stringify(finalTransform, null, 2));
|
||||
|
||||
console.log(`Stream sources positioned in nested scene "${streamGroupName}"`);
|
||||
} catch (positionError) {
|
||||
console.error('Failed to position sources:', positionError.message || positionError);
|
||||
|
@ -444,6 +531,281 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
|
|||
}
|
||||
}
|
||||
|
||||
// Comprehensive stream deletion function
|
||||
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 streamGroupName = `${cleanGroupName}_${cleanStreamName}_stream`;
|
||||
const sourceName = `${cleanGroupName}_${cleanStreamName}`;
|
||||
const textSourceName = teamName.toLowerCase().replace(/\s+/g, '_') + '_text';
|
||||
|
||||
console.log(`Starting comprehensive deletion for stream "${streamName}"`);
|
||||
console.log(`Components to delete: scene="${streamGroupName}", source="${sourceName}"`);
|
||||
|
||||
// 1. Remove stream group scene item from team scene (if it exists)
|
||||
try {
|
||||
const { sceneItems: teamSceneItems } = await obsClient.call('GetSceneItemList', { sceneName: groupName });
|
||||
const streamGroupItem = teamSceneItems.find(item => item.sourceName === streamGroupName);
|
||||
|
||||
if (streamGroupItem) {
|
||||
await obsClient.call('RemoveSceneItem', {
|
||||
sceneName: groupName,
|
||||
sceneItemId: streamGroupItem.sceneItemId
|
||||
});
|
||||
console.log(`Removed stream group "${streamGroupName}" from team scene "${groupName}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Team scene "${groupName}" not found or stream group not in it:`, error.message);
|
||||
}
|
||||
|
||||
// 2. Remove the nested scene (stream group)
|
||||
try {
|
||||
await obsClient.call('RemoveScene', { sceneName: streamGroupName });
|
||||
console.log(`Removed nested scene "${streamGroupName}"`);
|
||||
} catch (error) {
|
||||
console.log(`Nested scene "${streamGroupName}" not found:`, error.message);
|
||||
}
|
||||
|
||||
// 3. Remove the browser source (if it's not used elsewhere)
|
||||
try {
|
||||
const { inputs } = await obsClient.call('GetInputList');
|
||||
const browserSource = inputs.find(input => input.inputName === sourceName);
|
||||
|
||||
if (browserSource) {
|
||||
await obsClient.call('RemoveInput', { inputUuid: browserSource.inputUuid });
|
||||
console.log(`Removed browser source "${sourceName}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Browser source "${sourceName}" not found:`, error.message);
|
||||
}
|
||||
|
||||
// 4. Check if text source should be removed (only if no other streams from this team exist)
|
||||
try {
|
||||
// This would require checking if other streams from the same team exist
|
||||
// For now, we'll leave the text source as it's shared across team streams
|
||||
console.log(`Keeping shared text source "${textSourceName}" (shared across team streams)`);
|
||||
} catch (error) {
|
||||
console.log(`Error checking text source usage:`, error.message);
|
||||
}
|
||||
|
||||
// 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'
|
||||
];
|
||||
|
||||
for (const screen of screens) {
|
||||
try {
|
||||
await removeSourceFromSwitcher(screen, streamGroupName);
|
||||
console.log(`Removed "${streamGroupName}" from ${screen}`);
|
||||
} catch (error) {
|
||||
console.log(`Error removing from ${screen}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Comprehensive deletion completed for stream "${streamName}"`);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Stream components deleted successfully',
|
||||
deletedComponents: {
|
||||
streamGroupName,
|
||||
sourceName,
|
||||
removedFromSwitchers: screens.length
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in comprehensive stream deletion:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to remove source from source switcher
|
||||
async function removeSourceFromSwitcher(switcherName, sourceName) {
|
||||
try {
|
||||
const obsClient = await getOBSClient();
|
||||
|
||||
// Get current source switcher options
|
||||
const { inputSettings } = await obsClient.call('GetInputSettings', { inputName: switcherName });
|
||||
const currentSources = inputSettings.sources || [];
|
||||
|
||||
// Filter out the source we want to remove
|
||||
const updatedSources = currentSources.filter(source => source.value !== sourceName);
|
||||
|
||||
// Update the source switcher if changes were made
|
||||
if (updatedSources.length !== currentSources.length) {
|
||||
await obsClient.call('SetInputSettings', {
|
||||
inputName: switcherName,
|
||||
inputSettings: {
|
||||
...inputSettings,
|
||||
sources: updatedSources
|
||||
}
|
||||
});
|
||||
console.log(`Removed "${sourceName}" from ${switcherName} (${currentSources.length - updatedSources.length} instances)`);
|
||||
} else {
|
||||
console.log(`Source "${sourceName}" not found in ${switcherName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error removing source from ${switcherName}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to clear text files that reference the deleted stream
|
||||
async function clearTextFilesForStream(streamGroupName) {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
try {
|
||||
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files');
|
||||
const screens = [
|
||||
'large',
|
||||
'left',
|
||||
'right',
|
||||
'topLeft',
|
||||
'topRight',
|
||||
'bottomLeft',
|
||||
'bottomRight'
|
||||
];
|
||||
|
||||
let clearedFiles = [];
|
||||
|
||||
for (const screen of screens) {
|
||||
try {
|
||||
const filePath = path.join(FILE_DIRECTORY, `${screen}.txt`);
|
||||
|
||||
// Check if file exists and read its content
|
||||
if (fs.existsSync(filePath)) {
|
||||
const currentContent = fs.readFileSync(filePath, 'utf8').trim();
|
||||
|
||||
// If the file contains the stream group name we're deleting, clear it
|
||||
if (currentContent === streamGroupName) {
|
||||
fs.writeFileSync(filePath, '');
|
||||
clearedFiles.push(screen);
|
||||
console.log(`Cleared ${screen}.txt (was referencing deleted stream "${streamGroupName}")`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error checking/clearing ${screen}.txt:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
clearedFiles,
|
||||
message: `Cleared ${clearedFiles.length} text files that referenced the deleted stream`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error clearing text files:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Comprehensive team deletion function
|
||||
async function deleteTeamComponents(teamName, groupName) {
|
||||
try {
|
||||
const obsClient = await getOBSClient();
|
||||
|
||||
console.log(`Starting comprehensive deletion for team "${teamName}"`);
|
||||
|
||||
// 1. Delete the team scene (group)
|
||||
if (groupName) {
|
||||
try {
|
||||
await obsClient.call('RemoveScene', { sceneName: groupName });
|
||||
console.log(`Removed team scene "${groupName}"`);
|
||||
} catch (error) {
|
||||
console.log(`Team scene "${groupName}" not found or already deleted:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Delete the team text source (shared across all team streams)
|
||||
const textSourceName = teamName.toLowerCase().replace(/\s+/g, '_') + '_text';
|
||||
try {
|
||||
const { inputs } = await obsClient.call('GetInputList');
|
||||
const textSource = inputs.find(input => input.inputName === textSourceName);
|
||||
|
||||
if (textSource) {
|
||||
await obsClient.call('RemoveInput', { inputUuid: textSource.inputUuid });
|
||||
console.log(`Removed team text source "${textSourceName}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Text source "${textSourceName}" not found:`, error.message);
|
||||
}
|
||||
|
||||
// 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, '_');
|
||||
|
||||
// Find all nested stream scenes for this team
|
||||
const streamScenes = scenes.filter(scene =>
|
||||
scene.sceneName.startsWith(`${cleanGroupName}_`) &&
|
||||
scene.sceneName.endsWith('_stream')
|
||||
);
|
||||
|
||||
console.log(`Found ${streamScenes.length} stream scenes to delete`);
|
||||
|
||||
// Delete each stream scene
|
||||
for (const streamScene of streamScenes) {
|
||||
try {
|
||||
await obsClient.call('RemoveScene', { sceneName: streamScene.sceneName });
|
||||
console.log(`Removed stream scene "${streamScene.sceneName}"`);
|
||||
} catch (error) {
|
||||
console.log(`Error removing stream scene "${streamScene.sceneName}":`, error.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error finding stream scenes:`, error.message);
|
||||
}
|
||||
|
||||
// 4. Remove any browser sources associated with this team
|
||||
try {
|
||||
const { inputs } = await obsClient.call('GetInputList');
|
||||
const cleanGroupName = (groupName || teamName).toLowerCase().replace(/\s+/g, '_');
|
||||
|
||||
// Find all browser sources for this team
|
||||
const teamBrowserSources = inputs.filter(input =>
|
||||
input.inputKind === 'browser_source' &&
|
||||
input.inputName.startsWith(`${cleanGroupName}_`)
|
||||
);
|
||||
|
||||
console.log(`Found ${teamBrowserSources.length} browser sources to delete`);
|
||||
|
||||
// Delete each browser source
|
||||
for (const source of teamBrowserSources) {
|
||||
try {
|
||||
await obsClient.call('RemoveInput', { inputUuid: source.inputUuid });
|
||||
console.log(`Removed browser source "${source.inputName}"`);
|
||||
} catch (error) {
|
||||
console.log(`Error removing browser source "${source.inputName}":`, error.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error finding browser sources:`, error.message);
|
||||
}
|
||||
|
||||
console.log(`Comprehensive team deletion completed for "${teamName}"`);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Team components deleted successfully',
|
||||
deletedComponents: {
|
||||
teamScene: groupName,
|
||||
textSource: textSourceName
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in comprehensive team deletion:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Export all functions
|
||||
module.exports = {
|
||||
|
@ -457,5 +819,9 @@ module.exports = {
|
|||
addSourceToGroup,
|
||||
createTextSource,
|
||||
createStreamGroup,
|
||||
getAvailableTextInputKind
|
||||
getAvailableTextInputKind,
|
||||
deleteStreamComponents,
|
||||
removeSourceFromSwitcher,
|
||||
clearTextFilesForStream,
|
||||
deleteTeamComponents
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue