Compare commits

...

2 Commits

Author SHA1 Message Date
bryce
c8336b01bc Merge remote-tracking branch 'refs/remotes/OBS_controller/main'
this is to bring the histories into alignment, this was worked on before a repo was created for it
2025-07-22 11:12:05 +12:00
bryce
0254a30685 init upload
Mostly working
** OBS WS **
- UI isn't connecting
- bot is connecting
2025-07-22 10:50:27 +12:00
4 changed files with 1110 additions and 0 deletions

85
index.html Normal file
View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OBS Remote Control</title>
<link rel="stylesheet" href="styles.css">
<!-- Font Awesome for easily accessible icons if you prefer, or just use custom SVGs -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
</head>
<body>
<div class="container">
<h1>OBS Remote Control</h1>
<!-- Connection Dialog and Status -->
<div class="connection-status">
<span id="status-light" class="status-light red"></span>
<span id="status-text">Disconnected</span>
<button id="connect-dialog-button">Connection Settings</button>
</div>
<div id="connection-dialog" class="dialog">
<h2>Connection Settings</h2>
<label for="obs-ip">OBS IP Address:</label>
<input type="text" id="obs-ip" value="127.0.0.1"><br>
<label for="obs-port">Port:</label>
<input type="number" id="obs-port" value="4444"><br>
<label for="obs-password">Password:</label>
<input type="password" id="obs-password"><br>
<button id="connect-button">Connect</button>
<button id="close-dialog-button">Close</button>
</div>
<!-- Scene Swapping -->
<div class="section">
<h2>Scene Transitions</h2>
<div class="button-grid">
<button class="transition-button" data-transition="fade" data-duration="300">Swap (Fade @300ms)</button>
<button class="transition-button" data-transition="fade" data-duration="500">Swap (Fade @500ms)</button>
<button class="transition-button" data-transition="stinger" data-stinger-name="Stinger 1">Swap (Stinger 1)</button>
<button class="transition-button" data-transition="stinger" data-stinger-name="Stinger 2">Swap (Stinger 2)</button>
<button class="transition-button" data-transition="Move" data-duration="500">Swap (Move)</button>
<button class="transition-button" data-transition="Cut" data-duration="0">Swap (Cut)</button>
</div>
</div>
<!-- Scene Chooser (Studio Mode Only) - MOVED HERE -->
<div class="section">
<h2>Scene Chooser (Preview)</h2>
<div id="scene-thumbnails" class="scene-thumbnails-grid">
<!-- Thumbnails will be loaded here by JavaScript -->
</div>
</div>
<!-- Volume Sliders and Mute Buttons (Audio Control) -->
<div class="section">
<h2>Audio Control</h2>
<div class="audio-controls-grid" id="audio-controls-grid">
<!-- Audio sources will be dynamically added here by JavaScript -->
</div>
</div>
<!-- Mute/Unmute Groups - RESTRUCTURED -->
<div class="section">
<h2>Mute Groups</h2>
<div class="mute-groups-grid">
<button id="toggle-all-mics" class="group-mute-toggle" data-group="mics">
<!-- SVG will be injected by JS -->
<span>Mics</span>
</button>
<button id="toggle-non-mics" class="group-mute-toggle" data-group="non-mics">
<!-- SVG will be injected by JS -->
<span>Other Audio</span>
</button>
</div>
</div>
</div>
<script src="obs-websocket.js"></script>
<script src="script.js"></script>
</body>
</html>

1
obs-websocket.js Normal file

File diff suppressed because one or more lines are too long

705
script.js Normal file
View File

@@ -0,0 +1,705 @@
// --- OBS WebSocket Instance ---
const obs = new OBSWebSocket();
// --- DOM Elements ---
const statusLight = document.getElementById('status-light');
const statusText = document.getElementById('status-text');
const connectDialogButton = document.getElementById('connect-dialog-button');
const connectionDialog = document.getElementById('connection-dialog');
const obsIpInput = document.getElementById('obs-ip');
const obsPortInput = document.getElementById('obs-port');
const obsPasswordInput = document.getElementById('obs-password');
const connectButton = document.getElementById('connect-button');
const closeDialogButton = document.getElementById('close-dialog-button');
const transitionButtons = document.querySelectorAll('.transition-button');
const audioControlsGrid = document.getElementById('audio-controls-grid');
const sceneThumbnailsContainer = document.getElementById('scene-thumbnails');
// New group button references
const toggleAllMicsButton = document.getElementById('toggle-all-mics');
const toggleNonMicsButton = document.getElementById('toggle-non-mics');
// --- Configuration (Editable based on your OBS setup) ---
const MIC_SOURCES = ['Room Mic', 'Headset 1', 'Wireless'];
const MUSIC_SOURCES = ['Music', 'SoundDesk'];
const ALERT_SOURCES = ['Alerts'];
const ALL_AUDIO_SOURCES = [
'Desktop Audio', 'Headset', 'Wireless', 'SoundDesk', 'Kofi Alert', 'Kofi GOAL'
]; // Explicitly list all desired sources
const STINGER_INFO = {
'Stinger 1': { name: 'Stinger 1', duration: 3000 },
'Stinger 2': { name: 'Stinger 2', duration: 3000 }
};
let currentProgramScene = '';
let autoReconnectInterval = null;
const AUTO_RECONNECT_DELAY = 5000;
// --- SVG Icons (same as before) ---
const micIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.2-3c0 3.53-2.64 6.4-6 6.7V22h-2v-4.3c-3.36-.3-6-3.17-6-6.7H3c0 4.3 3.31 7.7 7.5 8.2V22h3v-4.3c4.19-.5 7.5-3.9 7.5-8.2h-2z"/>
</svg>
`;
const micMutedIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M19 11h-1.7c0 .72-.11 1.39-.31 2.02l1.46 1.46c.54-.93.85-2 .85-3.48zm-1.88 7.32l-1.41-1.41c-.6.35-1.29.6-2.05.77V22h-2v-3.08c-3.12-.35-5.69-2.9-5.99-6H3c.36 4.67 3.92 8.44 8.5 9v3h3v-3.08c1.37-.24 2.65-.86 3.75-1.78l2.05 2.05L22 19.32 19.12 16.44zM4.2 11h2.08c.17-2.63 2.45-4.8 5.16-4.99V3h2v3.01c.79.08 1.5.29 2.12.57l1.41-1.41c-.99-.61-2.11-1.05-3.3-1.25V1h-2v3.01c-.72.08-1.39.27-2.02.5L4.2 8.79zm7.8-4c-.73 0-1.36.17-1.92.42l4.31 4.31c.14-.59.21-1.2.21-1.83V5c0-1.66-1.34-3-3-3S9 3.34 9 5v2c0 .09.01.17.02.26L11.5 9.74c.16-.01.32-.02.5-.02z"/>
</svg>
`;
const musicIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6zm-2 16c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>
</svg>
`;
const musicMutedIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M14.07 1.83L12 3.89V1c-.59.34-1.27.55-2 .55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V4.93l-1.07-1.07zm-2.88 17.58c.28.35.59.66.92.93l1.41 1.41c-.48-.05-.96-.11-1.46-.11-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6zm4-5.32c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM.67 2.05l1.63 1.63C1.65 4.96 1 6.43 1 8c0 2.11 1.16 3.99 2.87 5.06l1.63 1.63C4.19 14.28 3 16.03 3 18c0 3.87 3.13 7 7 7 2.62 0 4.91-1.43 6.13-3.53l2.05 2.05 1.41-1.41L2.08.64.67 2.05zm10.7 10.7L12 13.91V11c-.59-.34-1.27-.55-2-.55-1.57 0-2.92.83-3.64 2.07L7.4 15.11C8.25 14.16 9.09 13.56 10 13.56c.73 0 1.36.17 1.92.42z"/>
</svg>
`;
const alertIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>
`;
const alertMutedIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M15.73 3H8.27L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27L15.73 3zM12 17c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm1-4h-2V7h2v6z"/>
</svg>
`;
// Group Icons (using generic ones for now, customize if needed)
const groupMicsIcon = micIcon; // Use the regular mic icon for the group
const groupMicsMutedIcon = micMutedIcon; // Use the muted mic icon for the group
// For "Other Audio" group, perhaps a speaker or headphones icon?
// Using the music icon for now, you can create a speaker/headphones SVG if you like.
const groupNonMicsIcon = musicIcon;
const groupNonMicsMutedIcon = musicMutedIcon;
// --- Connection Logic (same as before, with .classList.add/remove fix) ---
function updateConnectionStatus(isConnected, reconnecting = false) {
if (isConnected) {
statusLight.className = 'status-light green';
statusText.textContent = 'Connected';
clearInterval(autoReconnectInterval);
autoReconnectInterval = null;
} else {
statusLight.className = reconnecting ? 'status-light amber' : 'status-light red';
statusText.textContent = reconnecting ? 'Attempting to Reconnect...' : 'Disconnected';
if (!reconnecting && !autoReconnectInterval) {
autoReconnectInterval = setInterval(attemptConnect, AUTO_RECONNECT_DELAY);
}
}
}
async function attemptConnect() {
const ip = obsIpInput.value || '127.0.0.1';
const port = obsPortInput.value || '4455';
const password = obsPasswordInput.value || 'dAJyC3vMggSchQPN';
localStorage.setItem('obsIp', ip);
localStorage.setItem('obsPort', port);
localStorage.setItem('obsPassword', password);
updateConnectionStatus(false, true);
try {
console.log('Attempting to connect to OBS WebSocket...');
await obs.connect(`ws://${ip}:${port}`, password);
console.log('Connect method returned. OBS Connection Opened event will fire if successful.');
updateConnectionStatus(true);
connectionDialog.classList.remove('show'); // Use class to hide dialog
} catch (error) {
console.error('Failed to connect to OBS:', error);
updateConnectionStatus(false);
}
}
async function initializeOBSState() {
try {
const { studioModeEnabled } = await obs.call('GetStudioModeEnabled');
if (!studioModeEnabled) {
await obs.call('SetStudioModeEnabled', { studioModeEnabled: true });
console.log('Studio Mode enabled.');
} else {
console.log('Studio Mode already enabled.');
}
} catch (error) {
console.error('Error setting Studio Mode:', error);
}
await updateSceneThumbnails();
try {
const { currentProgramSceneName } = await obs.call('GetCurrentProgramScene');
currentProgramScene = currentProgramSceneName;
highlightActiveThumbnail(currentProgramScene);
} catch (error) {
console.error('Error getting current program scene:', error);
}
await updateAudioControls();
await updateGroupMuteButtons(); // Update the state of group mute buttons
}
// --- Event Listeners for Connection Dialog ---
connectDialogButton.addEventListener('click', () => {
connectionDialog.classList.add('show'); // Use class to show dialog
});
closeDialogButton.addEventListener('click', () => {
connectionDialog.classList.remove('show'); // Use class to hide dialog
});
connectButton.addEventListener('click', attemptConnect);
// Load saved settings on page load
window.addEventListener('load', () => {
obsIpInput.value = localStorage.getItem('obsIp') || '192.168.0.124';
obsPortInput.value = localStorage.getItem('obsPort') || '4455';
obsPasswordInput.value = localStorage.getItem('obsPassword') || 'dAJyC3vMggSchQPN';
attemptConnect();
});
// --- OBS Event Handlers ---
obs.on('ConnectionOpened', async () => {
console.log('OBS Connection Opened Event Fired!');
updateConnectionStatus(true);
// ADD A SMALL DELAY HERE
setTimeout(async () => {
try {
await initializeOBSState(); // Keep this one here
console.log('OBS State fully initialized via ConnectionOpened event after delay.');
} catch (error) {
console.error('Error during delayed OBS state initialization:', error);
}
}, 150); // Try 150ms delay. Adjust if needed (50-500ms)
});
obs.on('ConnectionClosed', () => {
console.log('OBS Connection Closed.');
updateConnectionStatus(false);
});
obs.on('ConnectionError', (error) => {
console.error('OBS Connection Error:', error);
updateConnectionStatus(false);
});
obs.on('CurrentProgramSceneChanged', async (data) => {
console.log('Program Scene Changed to:', data.sceneName);
currentProgramScene = data.sceneName;
highlightActiveThumbnail(currentProgramScene);
// When scene changes, mute states might change. Re-evaluate and update group buttons.
await updateGroupMuteButtons();
});
obs.on('CurrentPreviewSceneChanged', (data) => {
console.log('Preview Scene Changed to:', data.sceneName);
highlightActiveThumbnail(data.sceneName, true);
});
obs.on('SceneListChanged', async () => {
console.log('Scene List Changed. Updating thumbnails...');
await updateSceneThumbnails();
});
obs.on('InputVolumeMeters', (data) => {
data.inputs.forEach(input => {
const slider = document.querySelector(`.volume-slider[data-source="${input.inputName}"]`);
if (slider && !slider.classList.contains('dragging')) {
const volumePercent = Math.round(Math.pow(10, input.inputVolumeDb / 20) * 100);
slider.value = Math.max(0, Math.min(100, volumePercent));
}
});
});
obs.on('InputMuteStateChanged', async (data) => {
updateMuteButtonIcon(data.inputName, data.inputMuted);
// After individual mute state changes, re-evaluate group mute button states
await updateGroupMuteButtons();
});
// --- Scene Swapping Logic (same as before) ---
transitionButtons.forEach(button => {
button.addEventListener('click', async () => {
const transitionType = button.dataset.transition;
const duration = parseInt(button.dataset.duration);
const stingerName = button.dataset.stingerName;
if (!obs.connected) {
alert('Not connected to OBS!');
return;
}
const sceneBeforeTransition = currentProgramScene;
try {
await applyActionsForScene(sceneBeforeTransition, 'leaving');
if (transitionType === 'stinger' && stingerName) {
await obs.call('SetCurrentSceneTransition', { sceneTransitionName: stingerName });
await obs.call('TransitionToProgram');
} else {
await obs.call('SetCurrentSceneTransition', {
sceneTransitionName: transitionType
});
if (duration !== undefined) {
await obs.call('SetCurrentSceneTransitionDuration', {
sceneTransitionDuration: duration
});
}
await obs.call('TransitionToProgram');
}
console.log(`Transitioned with ${transitionType}`);
} catch (error) {
console.error('Error during scene transition:', error);
alert(`Error performing transition: ${error.message}`);
}
});
});
async function applyActionsForScene(sceneName, actionType) {
console.log(`Applying ${actionType} actions for scene: ${sceneName}`);
switch (sceneName) {
case 'Roving':
if (actionType === 'leaving') {
await setInputMuteState('Room Mic', true);
await setInputMuteState('Music', true);
await setInputMuteState('Wireless', false);
await toggleRecording(true);
showAlert('Action: Set stream marker for Start of B-Roll / Interview');
}
break;
case 'Room 1':
case 'Room 2':
if (actionType === 'leaving') {
await setInputMuteState('Wireless', true);
await setInputMuteState('Music', false);
await setInputMuteState('SoundDesk', false);
await toggleRecording(false);
showAlert('Action: Set marker - End of B-Roll / Interview');
}
break;
case 'BRB / Break':
if (actionType === 'leaving') {
await muteAllMicsGroup(true); // Call the group mute function
}
break;
case 'Starting':
if (actionType === 'leaving') {
await muteAllMicsGroup(true); // Call the group mute function
await setInputMuteState('Alerts', false);
await setInputMuteState('Music', false);
}
break;
case 'END / Raiding':
if (actionType === 'leaving') {
await setInputMuteState('Music', true);
await setInputMuteState('SoundDesk', true);
await setInputMuteState('Wireless', true);
await setInputMuteState('Headset 1', false);
}
break;
default:
console.log(`No specific ${actionType} actions for scene: ${sceneName}`);
break;
}
}
// --- Audio Control Logic with new Mute/Unmute Buttons (same as before) ---
async function updateAudioControls() {
if (!obs.connected) return;
audioControlsGrid.innerHTML = '';
for (const sourceName of ALL_AUDIO_SOURCES) {
const audioSourceDiv = document.createElement('div');
audioSourceDiv.classList.add('audio-source');
const label = document.createElement('label');
label.textContent = sourceName;
label.htmlFor = `volume-${sourceName.replace(/\s/g, '-')}`;
const slider = document.createElement('input');
slider.type = 'range';
slider.id = `volume-${sourceName.replace(/\s/g, '-')}`;
slider.classList.add('volume-slider');
slider.setAttribute('orient', 'vertical');
slider.min = '0';
slider.max = '100';
slider.step = '1';
slider.dataset.source = sourceName;
const muteButton = document.createElement('button');
muteButton.classList.add('mute-toggle-button');
muteButton.dataset.source = sourceName;
let iconHtml;
if (MIC_SOURCES.includes(sourceName)) {
iconHtml = micIcon;
} else if (MUSIC_SOURCES.includes(sourceName)) {
iconHtml = musicIcon;
} else if (ALERT_SOURCES.includes(sourceName)) {
iconHtml = alertIcon;
} else {
iconHtml = '';
}
muteButton.innerHTML = iconHtml;
audioSourceDiv.appendChild(label);
audioSourceDiv.appendChild(slider);
audioSourceDiv.appendChild(muteButton);
audioControlsGrid.appendChild(audioSourceDiv);
try {
const { inputVolumeDb } = await obs.call('GetInputVolume', { inputName: sourceName });
const volumePercent = Math.round(Math.pow(10, inputVolumeDb / 20) * 100);
slider.value = Math.max(0, Math.min(100, volumePercent));
} catch (err) {
console.error(`Error getting volume for ${sourceName}:`, err);
}
try {
const { inputMuted } = await obs.call('GetInputMute', { inputName: sourceName });
updateMuteButtonIcon(sourceName, inputMuted);
} catch (err) {
console.error(`Error getting mute state for ${sourceName}:`, err);
}
}
document.querySelectorAll('.volume-slider').forEach(slider => {
slider.addEventListener('mousedown', () => slider.classList.add('dragging'));
slider.addEventListener('mouseup', () => slider.classList.remove('dragging'));
slider.addEventListener('input', async (e) => {
const source = e.target.dataset.source;
const volume = parseFloat(e.target.value);
const volumeDb = 20 * Math.log10(Math.max(0.0001, volume / 100));
try {
await obs.call('SetInputVolume', { inputName: source, inputVolumeDb: volumeDb });
} catch (error) {
console.error(`Error setting volume for ${source}:`, error);
}
});
});
document.querySelectorAll('.mute-toggle-button').forEach(button => {
button.addEventListener('click', async (e) => {
const source = e.currentTarget.dataset.source;
const isMuted = e.currentTarget.classList.contains('muted');
await setInputMuteState(source, !isMuted);
});
});
}
function updateMuteButtonIcon(sourceName, isMuted) {
const button = document.querySelector(`.mute-toggle-button[data-source="${sourceName}"]`);
if (!button) return;
let iconToUse;
if (isMuted) {
if (MIC_SOURCES.includes(sourceName)) {
iconToUse = micMutedIcon;
} else if (MUSIC_SOURCES.includes(sourceName) || ALERT_SOURCES.includes(sourceName)) {
// Use muted music icon for both music and alerts, or make a separate alert muted icon
iconToUse = musicMutedIcon;
} else {
iconToUse = '';
}
button.classList.add('muted');
} else {
if (MIC_SOURCES.includes(sourceName)) {
iconToUse = micIcon;
} else if (MUSIC_SOURCES.includes(sourceName)) {
iconToUse = musicIcon;
} else if (ALERT_SOURCES.includes(sourceName)) {
iconToUse = alertIcon;
} else {
iconToUse = '';
}
button.classList.remove('muted');
}
button.innerHTML = iconToUse;
}
async function setInputMuteState(sourceName, mute) {
if (!obs.connected) {
console.warn(`Not connected to OBS, cannot mute/unmute ${sourceName}.`);
return;
}
try {
await obs.call('SetInputMute', { inputName: sourceName, inputMuted: mute });
console.log(`${sourceName} ${mute ? 'muted' : 'unmuted'}`);
// OBS will fire InputMuteStateChanged, which will then call updateMuteButtonIcon and updateGroupMuteButtons
} catch (error) {
console.error(`Error setting mute state for ${sourceName}:`, error);
}
}
// --- Mute/Unmute Groups - NEW TOGGLE LOGIC ---
// Initial icon setup for group buttons
toggleAllMicsButton.innerHTML = groupMicsIcon + '<span>Mics</span>';
toggleNonMicsButton.innerHTML = groupNonMicsIcon + '<span>Other Audio</span>';
toggleAllMicsButton.addEventListener('click', async (e) => {
const isMuted = e.currentTarget.classList.contains('muted'); // Check if currently showing muted state
await muteAllMicsGroup(!isMuted); // Toggle the state
});
toggleNonMicsButton.addEventListener('click', async (e) => {
const isMuted = e.currentTarget.classList.contains('muted');
await muteNonMicsGroup(!isMuted);
});
async function muteAllMicsGroup(mute) {
// Determine the target state for all mics (either all mute or all unmute)
// Only execute if OBS is connected
if (!obs.connected) {
console.warn('Not connected to OBS, cannot mute/unmute group.');
return;
}
try {
for (const mic of MIC_SOURCES) {
await setInputMuteState(mic, mute);
}
console.log(`All mics group ${mute ? 'muted' : 'unmuted'}.`);
} catch (error) {
console.error('Error muting/unmuting mic group:', error);
}
}
async function muteNonMicsGroup(mute) {
const nonMics = ALL_AUDIO_SOURCES.filter(source => !MIC_SOURCES.includes(source));
if (!obs.connected) {
console.warn('Not connected to OBS, cannot mute/unmute group.');
return;
}
try {
for (const source of nonMics) {
await setInputMuteState(source, mute);
}
console.log(`All non-mic group ${mute ? 'muted' : 'unmuted'}.`);
} catch (error) {
console.error('Error muting/unmuting non-mic group:', error);
}
}
// Function to update the visual state of the group mute buttons
async function updateGroupMuteButtons() {
if (!obs.connected) {
toggleAllMicsButton.classList.remove('muted');
toggleAllMicsButton.innerHTML = groupMicsIcon + '<span>Mics</span>';
toggleNonMicsButton.classList.remove('muted');
toggleNonMicsButton.innerHTML = groupNonMicsIcon + '<span>Other Audio</span>';
return;
}
// Check state of all mic sources
let allMicsMuted = true;
for (const mic of MIC_SOURCES) {
try {
const { inputMuted } = await obs.call('GetInputMute', { inputName: mic });
if (!inputMuted) {
allMicsMuted = false;
break; // If any mic is unmuted, the group is not fully muted
}
} catch (error) {
console.warn(`Could not get mute state for ${mic} (might not exist):`, error.message);
allMicsMuted = false; // Treat non-existent as unmuted for group status
break;
}
}
if (allMicsMuted) {
toggleAllMicsButton.classList.add('muted');
toggleAllMicsButton.innerHTML = groupMicsMutedIcon + '<span>Mics</span>';
} else {
toggleAllMicsButton.classList.remove('muted');
toggleAllMicsButton.innerHTML = groupMicsIcon + '<span>Mics</span>';
}
// Check state of all non-mic sources
let allNonMicsMuted = true;
const nonMics = ALL_AUDIO_SOURCES.filter(source => !MIC_SOURCES.includes(source));
for (const source of nonMics) {
try {
const { inputMuted } = await obs.call('GetInputMute', { inputName: source });
if (!inputMuted) {
allNonMicsMuted = false;
break;
}
} catch (error) {
console.warn(`Could not get mute state for ${source} (might not exist):`, error.message);
allNonMicsMuted = false;
break;
}
}
if (allNonMicsMuted) {
toggleNonMicsButton.classList.add('muted');
toggleNonMicsButton.innerHTML = groupNonMicsMutedIcon + '<span>Other Audio</span>';
} else {
toggleNonMicsButton.classList.remove('muted');
toggleNonMicsButton.innerHTML = groupNonMicsIcon + '<span>Other Audio</span>';
}
}
// --- Recording Control (same as before) ---
async function toggleRecording(start) {
if (!obs.connected) {
console.warn('Not connected to OBS, cannot toggle recording.');
return;
}
try {
const { outputActive } = await obs.call('GetRecordStatus');
if (start && !outputActive) {
await obs.call('StartRecord');
console.log('Recording started.');
} else if (!start && outputActive) {
await obs.call('StopRecord');
console.log('Recording stopped.');
} else {
console.log(`Recording is already ${outputActive ? 'active' : 'inactive'}. No change.`);
}
} catch (error) {
console.error('Error toggling recording:', error);
}
}
// --- Alerts (for Stream Markers) ---
function showAlert(message) {
alert(message);
console.log('Stream Marker Alert:', message);
}
// --- Scene Chooser (Studio Mode) (same as before) ---
async function updateSceneThumbnails() {
if (!obs.connected) return;
try {
const { scenes } = await obs.call('GetSceneList');
sceneThumbnailsContainer.innerHTML = '';
const { currentProgramSceneName } = await obs.call('GetCurrentProgramScene');
const { currentPreviewSceneName } = await obs.call('GetCurrentPreviewScene');
for (const scene of scenes) {
const sceneName = scene.sceneName;
const thumbnailDiv = document.createElement('div');
thumbnailDiv.classList.add('scene-thumbnail');
thumbnailDiv.dataset.sceneName = sceneName;
const img = document.createElement('img');
img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
img.alt = sceneName;
img.style.transition = 'opacity 0.5s ease-in-out';
const span = document.createElement('span');
span.textContent = sceneName;
thumbnailDiv.appendChild(img);
thumbnailDiv.appendChild(span);
sceneThumbnailsContainer.appendChild(thumbnailDiv);
if (sceneName === currentPreviewSceneName) {
thumbnailDiv.classList.add('active');
} else if (sceneName === currentProgramSceneName) {
// Keep this empty, highlightActiveThumbnail handles un-previewing
}
thumbnailDiv.addEventListener('click', async () => {
if (!obs.connected) return;
try {
await obs.call('SetCurrentPreviewScene', { sceneName: sceneName });
console.log(`Set preview to: ${sceneName}`);
highlightActiveThumbnail(sceneName, true);
} catch (error) {
console.error(`Error setting preview scene to ${sceneName}:`, error);
}
});
}
startThumbnailRefresh();
} catch (error) {
console.error('Error fetching scenes for thumbnails:', error);
}
}
function highlightActiveThumbnail(sceneName, isPreview = false) {
document.querySelectorAll('.scene-thumbnail').forEach(thumb => {
if (isPreview) {
// If setting a preview, remove active from all others, then add to the target
if (thumb.dataset.sceneName === sceneName) {
thumb.classList.add('active');
} else {
thumb.classList.remove('active');
}
}
// No explicit handling for program scene in UI unless desired for a different visual cue
});
}
let thumbnailRefreshInterval = null;
const THUMBNAIL_REFRESH_RATE = 1000;
async function startThumbnailRefresh() {
if (thumbnailRefreshInterval) {
clearInterval(thumbnailRefreshInterval);
}
thumbnailRefreshInterval = setInterval(async () => {
if (!obs.connected) {
clearInterval(thumbnailRefreshInterval);
thumbnailRefreshInterval = null;
return;
}
try {
const { scenes } = await obs.call('GetSceneList');
console.log('OBS Scenes List:', scenes);
for (const scene of scenes) {
const sceneName = scene.sceneName;
const thumbnailDiv = document.querySelector(`.scene-thumbnail[data-scene-name="${sceneName}"]`);
if (thumbnailDiv) {
const img = thumbnailDiv.querySelector('img');
if (img) {
const { imageData } = await obs.call('GetSceneScreenshot', {
sceneName: sceneName,
imageFormat: 'png',
sourceWidth: 200,
sourceHeight: 112
});
img.style.opacity = '0';
setTimeout(() => {
img.src = imageData;
img.style.opacity = '1';
}, 100);
}
}
}
} catch (error) {
console.error('Error refreshing thumbnails:', error);
}
}, THUMBNAIL_REFRESH_RATE);
}
obs.on('ConnectionOpened', async () => {
console.log('OBS Connection Opened!');
updateConnectionStatus(true);
await initializeOBSState();
});

319
styles.css Normal file
View File

@@ -0,0 +1,319 @@
body {
font-family: Arial, sans-serif;
background-color: #2c2c2c;
color: #f0f0f0;
margin: 0;
padding: 20px;
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
}
.container {
background-color: #3a3a3a;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 800px;
}
h1, h2 {
color: #e0e0e0;
text-align: center;
margin-bottom: 20px;
}
.section {
margin-bottom: 30px;
padding: 15px;
background-color: #4a4a4a;
border-radius: 6px;
}
/* Connection Status */
.connection-status {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 20px;
}
.status-light {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #555;
}
.status-light.green { background-color: #28a745; } /* Connected */
.status-light.red { background-color: #dc3545; } /* Disconnected */
.status-light.amber { background-color: #ffc107; } /* Auto Reconnect */
/* Dialog */
.dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #3a3a3a;
padding: 30px;
border-radius: 10px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.5);
z-index: 1000;
text-align: center;
border: 1px solid #666;
/* These are the crucial lines for initial hiding and transition */
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
/* This is the class that makes it visible */
.dialog.show {
opacity: 1;
visibility: visible;
pointer-events: all;
}
.dialog label {
display: block;
margin-bottom: 8px;
font-weight: bold;
}
.dialog input {
width: calc(100% - 20px);
padding: 10px;
margin-bottom: 15px;
border: 1px solid #555;
background-color: #2c2c2c;
color: #f0f0f0;
border-radius: 4px;
}
/* Buttons */
button {
background-color: #007bff;
color: white;
padding: 10px 15px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
margin: 5px;
transition: background-color 0.2s ease;
display: flex; /* For icons to be centered easily */
align-items: center;
justify-content: center;
gap: 5px; /* Space between icon and text if any */
}
button:hover {
background-color: #0056b3;
}
.dialog button {
width: 120px;
}
.button-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
justify-content: center;
}
.transition-button {
background-color: #28a745; /* Green for transitions */
}
.transition-button:hover {
background-color: #218838;
}
/* Audio Controls */
.audio-controls-grid {
display: flex; /* Use flexbox for horizontal layout */
gap: 20px;
justify-content: center;
flex-wrap: wrap; /* Allow wrapping for smaller screens */
}
.audio-source {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 10px;
border: 1px solid #555;
border-radius: 5px;
background-color: #3a3a3a;
}
.volume-slider {
/* This style will attempt to make it vertical, but cross-browser support varies */
-webkit-appearance: slider-vertical; /* For Chrome, Safari, Edge */
writing-mode: bt-lr; /* For Firefox */
height: 150px; /* Adjust height as needed */
width: 15px; /* Adjust width as needed */
background: #555;
border-radius: 5px;
outline: none;
transition: opacity .2s;
margin-bottom: 10px;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 25px;
height: 25px;
border-radius: 50%;
background: #007bff;
cursor: pointer;
}
.volume-slider::-moz-range-thumb {
width: 25px;
height: 25px;
border-radius: 50%;
background: #007bff;
cursor: pointer;
}
/* Mute Toggle Button - Now with SVG */
.mute-toggle-button {
width: 60px; /* Make it square or adjust as needed for icon */
height: 60px;
padding: 0; /* Remove padding as content is an SVG */
background-color: #555; /* Default background */
border: 2px solid #666;
}
.mute-toggle-button:hover {
background-color: #777;
}
.mute-toggle-button svg {
width: 30px; /* Size of the SVG icon */
height: 30px;
transition: fill 0.2s ease, transform 0.1s ease;
}
/* Muted State Styles */
.mute-toggle-button.muted {
background-color: #dc3545; /* Red background when muted */
border-color: #bb2d3b;
}
.mute-toggle-button.muted:hover {
background-color: #bb2d3b;
}
/* SVG Fill Colors (active/unmuted vs muted) */
.mute-toggle-button svg path {
fill: #28a745; /* Green for unmuted (default) */
transition: fill 0.2s ease;
}
.mute-toggle-button.muted svg path {
fill: white; /* White for muted icons, contrasts with red background */
}
/* Scene Thumbnails */
.scene-thumbnails-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
justify-content: center;
}
.scene-thumbnail {
background-color: #555;
border: 2px solid #666;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
padding-bottom: 10px; /* Space for text */
}
.scene-thumbnail:hover {
border-color: #007bff;
box-shadow: 0 0 15px rgba(0, 123, 255, 0.5);
transform: translateY(-3px);
}
.scene-thumbnail.active {
border-color: #28a745; /* Green border for the active (preview) scene */
box-shadow: 0 0 15px rgba(40, 167, 69, 0.7);
}
.scene-thumbnail img {
width: 100%;
height: auto;
display: block;
border-bottom: 1px solid #666;
}
.scene-thumbnail span {
display: block;
padding-top: 8px;
font-size: 1.1em;
font-weight: bold;
color: #e0e0e0;
}
.mute-groups-grid {
display: flex; /* Use flexbox for layout */
justify-content: center;
gap: 20px;
flex-wrap: wrap;
margin-top: 20px;
}
.group-mute-toggle {
width: 120px; /* Adjust size as needed */
height: 100px; /* Adjust size as needed */
padding: 0;
flex-direction: column; /* Stack icon and text */
background-color: #6c757d; /* Default gray for group buttons */
border: 2px solid #555;
font-size: 1.1em;
font-weight: bold;
}
.group-mute-toggle:hover {
background-color: #5a6268;
}
.group-mute-toggle svg {
width: 40px; /* Larger icon for group buttons */
height: 40px;
margin-bottom: 5px; /* Space between icon and text */
transition: fill 0.2s ease;
}
/* Muted state for group buttons */
.group-mute-toggle.muted {
background-color: #dc3545; /* Red when group is muted */
border-color: #bb2d3b;
}
.group-mute-toggle.muted:hover {
background-color: #bb2d3b;
}
.group-mute-toggle svg path {
fill: white; /* White icon fill (contrast with button color) */
transition: fill 0.2s ease;
}
.group-mute-toggle.unmuted {
background-color: #28a745;
}