// --- 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 = ` `; const micMutedIcon = ` `; const musicIcon = ` `; const musicMutedIcon = ` `; const alertIcon = ` `; const alertMutedIcon = ` `; // 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 + 'Mics'; toggleNonMicsButton.innerHTML = groupNonMicsIcon + 'Other Audio'; 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 + 'Mics'; toggleNonMicsButton.classList.remove('muted'); toggleNonMicsButton.innerHTML = groupNonMicsIcon + 'Other Audio'; 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 + 'Mics'; } else { toggleAllMicsButton.classList.remove('muted'); toggleAllMicsButton.innerHTML = groupMicsIcon + 'Mics'; } // 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 + 'Other Audio'; } else { toggleNonMicsButton.classList.remove('muted'); toggleNonMicsButton.innerHTML = groupNonMicsIcon + 'Other Audio'; } } // --- 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(); });