Compare commits
2 Commits
d830f4157b
...
c8336b01bc
Author | SHA1 | Date | |
---|---|---|---|
|
c8336b01bc | ||
|
0254a30685 |
85
index.html
Normal file
85
index.html
Normal 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
1
obs-websocket.js
Normal file
File diff suppressed because one or more lines are too long
705
script.js
Normal file
705
script.js
Normal 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
319
styles.css
Normal 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;
|
||||
}
|
Reference in New Issue
Block a user