mirror of
https://github.com/SrIzan10/vdo.ninja.git
synced 2026-05-01 11:05:24 +00:00
1777 lines
63 KiB
HTML
1777 lines
63 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>VDO.Ninja OBS Control Dock</title>
|
||
<style>
|
||
body {
|
||
font-family: sans-serif;
|
||
margin: 5px;
|
||
background-color: #13141A;
|
||
color: #e0e0e0;
|
||
font-size: 13px;
|
||
}
|
||
.container {
|
||
margin-bottom: 10px;
|
||
padding: 8px;
|
||
background-color: #272A33;
|
||
border-radius: 5px;
|
||
border: 1px solid #3a3a48;
|
||
}
|
||
.collapsible {
|
||
cursor: pointer;
|
||
user-select: none;
|
||
padding: 5px 0;
|
||
position: relative;
|
||
}
|
||
.collapsible::after {
|
||
content: '▼';
|
||
position: absolute;
|
||
right: 0;
|
||
font-size: 11px;
|
||
}
|
||
.collapsible.collapsed::after {
|
||
content: '►';
|
||
}
|
||
.collapsible-content {
|
||
max-height: 1000px;
|
||
overflow: hidden;
|
||
transition: max-height 0.2s ease-out;
|
||
}
|
||
.collapsible-content.collapsed {
|
||
max-height: 0;
|
||
}
|
||
label {
|
||
display: block;
|
||
margin-bottom: 5px;
|
||
}
|
||
input[type="text"], input[type="password"], select {
|
||
width: calc(100% - 16px);
|
||
padding: 5px;
|
||
margin-bottom: 8px;
|
||
border: 1px solid #424254;
|
||
border-radius: 3px;
|
||
background-color: #3C404D;
|
||
color: #e0e0e0;
|
||
}
|
||
button {
|
||
padding: 6px 10px;
|
||
background-color: #3C404D;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
margin-right: 3px;
|
||
margin-bottom: 3px;
|
||
font-size: 12px;
|
||
}
|
||
button.connected {
|
||
background-color: #4C80AF;
|
||
}
|
||
button.disconnected {
|
||
background-color: #484860;
|
||
}
|
||
button:hover {
|
||
background-color: #5a5a7a;
|
||
}
|
||
.blur-field {
|
||
filter: blur(5px);
|
||
transition: filter 0.2s ease;
|
||
}
|
||
.blur-field:focus {
|
||
filter: blur(0);
|
||
}
|
||
#vdoNinjaIframe {
|
||
width: 1px;
|
||
height: 1px;
|
||
position: absolute;
|
||
left: -1000px;
|
||
top: -1000px;
|
||
border: 0;
|
||
}
|
||
.log-area {
|
||
height: 100px;
|
||
background-color: #1a1a24;
|
||
color: #ccc;
|
||
border: 1px solid #424254;
|
||
overflow-y: scroll;
|
||
padding: 5px;
|
||
font-family: monospace;
|
||
font-size: 0.9em;
|
||
margin-top: 5px;
|
||
white-space: pre-wrap;
|
||
}
|
||
.status-indicator {
|
||
display: inline-block;
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
margin-left: 5px;
|
||
background-color: #555;
|
||
}
|
||
.status-indicator.connected {
|
||
background-color: #4C80AF;
|
||
}
|
||
.status-indicator.error {
|
||
background-color: #f44336;
|
||
}
|
||
.stream-list {
|
||
max-height: 120px;
|
||
overflow-y: auto;
|
||
background-color: #1a1a24;
|
||
border: 1px solid #424254;
|
||
border-radius: 3px;
|
||
padding: 5px;
|
||
margin-top: 3px;
|
||
}
|
||
.stream-item {
|
||
padding: 4px;
|
||
border-bottom: 1px solid #3c3c4a;
|
||
font-size: 12px;
|
||
word-break: break-word;
|
||
}
|
||
.stream-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
h1, h2 {
|
||
color: #c8c8c8;
|
||
margin: 5px 0;
|
||
font-size: 1.1em;
|
||
}
|
||
h1 {
|
||
font-size: 1.2em;
|
||
}
|
||
small {
|
||
color: #8a8a9a;
|
||
font-size: 0.85em;
|
||
}
|
||
.add-stream-btn {
|
||
background-color: #4C80AF;
|
||
color: white;
|
||
padding: 2px 4px;
|
||
font-size: 11px;
|
||
}
|
||
.status-line {
|
||
font-size: 12px;
|
||
margin-top: 5px;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
input[type="checkbox"] {
|
||
accent-color: #4C80AF;
|
||
margin-right: 5px;
|
||
}
|
||
.checkbox-label {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 3px;
|
||
}
|
||
.checkbox-label input {
|
||
margin-right: 4px;
|
||
}
|
||
::-webkit-scrollbar {
|
||
width: 6px;
|
||
height: 6px;
|
||
}
|
||
::-webkit-scrollbar-track {
|
||
background: #1a1a24;
|
||
border-radius: 3px;
|
||
}
|
||
::-webkit-scrollbar-thumb {
|
||
background: #3C404D;
|
||
border-radius: 3px;
|
||
}
|
||
::-webkit-scrollbar-thumb:hover {
|
||
background: #4C80AF;
|
||
}
|
||
* {
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #3C404D #1a1a24;
|
||
}
|
||
.flex-row {
|
||
display: flex;
|
||
gap: 4px;
|
||
flex-wrap: wrap;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>VDO.Ninja OBS Control</h1>
|
||
|
||
<div class="container">
|
||
<h2 class="collapsible">OBS WebSocket Connection</h2>
|
||
<div class="collapsible-content">
|
||
<label for="obsWsUrl">WebSocket URL:</label>
|
||
<input type="text" id="obsWsUrl" value="ws://localhost:4455">
|
||
|
||
<label for="obsWsPassword">Password:</label>
|
||
<input type="password" id="obsWsPassword" value="">
|
||
|
||
<div class="status-line">
|
||
<button id="obsConnectBtn">Connect to OBS</button>
|
||
<span id="obsConnectionStatus">Status: Disconnected</span>
|
||
<span id="obsStatusIndicator" class="status-indicator"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="container">
|
||
<h2 class="collapsible">VDO.Ninja Settings</h2>
|
||
<div class="collapsible-content">
|
||
<label for="vdoNinjaRoom">Room Name:</label>
|
||
<input type="text" id="vdoNinjaRoom" placeholder="e.g., MyNinjaRoom" class="blur-field">
|
||
|
||
<label for="vdoNinjaPassword">Password:</label>
|
||
<input type="password" id="vdoNinjaPassword" placeholder="Room or &password">
|
||
|
||
<label for="vdoNinjaStreamIds">Stream IDs:</label>
|
||
<input type="text" id="vdoNinjaStreamIds" placeholder="streamId1,streamId2" class="blur-field">
|
||
<small>Room Name or Stream ID(s) needed</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="container">
|
||
<h2 class="collapsible">OBS Target Settings</h2>
|
||
<div class="collapsible-content">
|
||
<label for="obsSceneNameInput">Target Scene:</label>
|
||
<input type="text" id="obsSceneNameInput" placeholder="e.g., Main Gameplay">
|
||
<select id="obsSceneSelect" style="display:none;"></select>
|
||
<button id="loadScenesBtn">Re-Fetch Scenes</button>
|
||
|
||
<label for="sourceSizing">New Source Sizing:</label>
|
||
|
||
<select id="sourceSizing">
|
||
<option value="autoGrid">Auto Grid Layout</option>
|
||
<option value="bestFit">Best Fit (Preserve Aspect)</option>
|
||
<option value="stretchToFill">Stretch to Fill Screen</option>
|
||
<option value="defaultSize">Default (1920x1080 at 0,0)</option>
|
||
</select>
|
||
|
||
<div id="autoSourceOptions">
|
||
<label class="checkbox-label">
|
||
<input type="checkbox" id="autoAddSources" checked>
|
||
Auto-add new streams as sources
|
||
</label>
|
||
<label class="checkbox-label">
|
||
<input type="checkbox" id="autoRemoveSources" checked>
|
||
Auto-remove sources on disconnect
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="container">
|
||
<h2 class="collapsible">Stream ID Mappings</h2>
|
||
<div class="collapsible-content">
|
||
<label class="checkbox-label">
|
||
<input type="checkbox" id="enableStreamMapping">
|
||
Map specific Stream IDs to scenes
|
||
</label>
|
||
<div id="streamMappingContainer" style="display:none;">
|
||
<div id="streamMappings"></div>
|
||
<button id="addStreamMappingBtn">Add New Mapping</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="container">
|
||
<h2 class="collapsible">Active Streams</h2>
|
||
<div class="collapsible-content">
|
||
<div id="streamList" class="stream-list">
|
||
<div class="stream-item">No active streams</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<iframe id="vdoNinjaIframe" allow="encrypted-media;sync-xhr;usb;web-share;cross-origin-isolated;midi *;geolocation;camera *;microphone *;fullscreen;picture-in-picture;display-capture;accelerometer;autoplay;gyroscope;screen-wake-lock;"></iframe>
|
||
|
||
<div class="container">
|
||
<h2 class="collapsible">Log</h2>
|
||
<div class="collapsible-content">
|
||
<div id="logArea" class="log-area"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// DOM elements
|
||
const obsWsUrlInput = document.getElementById('obsWsUrl');
|
||
const obsWsPasswordInput = document.getElementById('obsWsPassword');
|
||
const obsConnectBtn = document.getElementById('obsConnectBtn');
|
||
const obsConnectionStatus = document.getElementById('obsConnectionStatus');
|
||
const obsStatusIndicator = document.getElementById('obsStatusIndicator');
|
||
|
||
const vdoNinjaRoomInput = document.getElementById('vdoNinjaRoom');
|
||
const vdoNinjaPasswordInput = document.getElementById('vdoNinjaPassword');
|
||
const vdoNinjaStreamIdsInput = document.getElementById('vdoNinjaStreamIds');
|
||
const vdoNinjaIframe = document.getElementById('vdoNinjaIframe');
|
||
|
||
const obsSceneNameInput = document.getElementById('obsSceneNameInput');
|
||
const obsSceneSelect = document.getElementById('obsSceneSelect');
|
||
const sourceSizingSelect = document.getElementById('sourceSizing');
|
||
const autoAddSourcesCheckbox = document.getElementById('autoAddSources');
|
||
const autoRemoveSourcesCheckbox = document.getElementById('autoRemoveSources');
|
||
const streamListContainer = document.getElementById('streamList');
|
||
const logArea = document.getElementById('logArea');
|
||
const loadScenesBtn = document.getElementById('loadScenesBtn');
|
||
let vdoNinjaConnected = false;
|
||
|
||
// Set up collapsible sections
|
||
document.querySelectorAll('.collapsible').forEach(header => {
|
||
header.addEventListener('click', function() {
|
||
this.classList.toggle('collapsed');
|
||
const content = this.nextElementSibling;
|
||
if (content.classList.contains('collapsible-content')) {
|
||
content.classList.toggle('collapsed');
|
||
}
|
||
});
|
||
});
|
||
|
||
const vdoNinjaConnectBtn = document.createElement('button');
|
||
vdoNinjaConnectBtn.id = 'vdoNinjaConnectBtn';
|
||
vdoNinjaConnectBtn.textContent = 'Connect to VDO.Ninja';
|
||
vdoNinjaConnectBtn.style.marginTop = '5px';
|
||
|
||
const vdoNinjaStatusIndicator = document.createElement('span');
|
||
vdoNinjaStatusIndicator.id = 'vdoNinjaStatusIndicator';
|
||
vdoNinjaStatusIndicator.className = 'status-indicator';
|
||
|
||
const vdoNinjaConnectionStatus = document.createElement('span');
|
||
vdoNinjaConnectionStatus.id = 'vdoNinjaConnectionStatus';
|
||
vdoNinjaConnectionStatus.textContent = 'Status: Disconnected';
|
||
vdoNinjaConnectionStatus.style.marginLeft = '5px';
|
||
|
||
// Add these elements after the vdoNinjaPassword input
|
||
const vdoNinjaSettingsContainer = document.querySelector('.container:nth-child(2)');
|
||
const buttonsDiv = document.createElement('div');
|
||
buttonsDiv.style.marginTop = '5px';
|
||
buttonsDiv.className = 'status-line';
|
||
buttonsDiv.appendChild(vdoNinjaConnectBtn);
|
||
buttonsDiv.appendChild(vdoNinjaConnectionStatus);
|
||
buttonsDiv.appendChild(vdoNinjaStatusIndicator);
|
||
vdoNinjaSettingsContainer.querySelector('.collapsible-content').appendChild(buttonsDiv);
|
||
|
||
vdoNinjaConnectBtn.addEventListener('click', () => {
|
||
if (vdoNinjaConnected) {
|
||
disconnectFromVdoNinja();
|
||
} else {
|
||
connectToVdoNinja();
|
||
}
|
||
});
|
||
|
||
// State variables
|
||
let obs = null;
|
||
let obsConnected = false;
|
||
let activeStreams = {};
|
||
let obsScenes = [];
|
||
let requestCallbacks = {};
|
||
|
||
let vdoNinjaLastActivityTime = 0;
|
||
let vdoNinjaConnectionCheckTimer = null;
|
||
|
||
// Helper functions
|
||
function logMessage(message) {
|
||
console.log(message);
|
||
const timestamp = new Date().toLocaleTimeString();
|
||
logArea.innerHTML += `[${timestamp}] ${message}\n`;
|
||
logArea.scrollTop = logArea.scrollHeight;
|
||
}
|
||
|
||
function generateRequestId(type) {
|
||
return `${type}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
||
}
|
||
|
||
function toggleVdoNinjaInputs(disabled) {
|
||
vdoNinjaRoomInput.disabled = disabled;
|
||
vdoNinjaPasswordInput.disabled = disabled;
|
||
vdoNinjaStreamIdsInput.disabled = disabled;
|
||
}
|
||
function updateVdoNinjaButtonState(connected) {
|
||
vdoNinjaConnected = connected;
|
||
|
||
if (connected) {
|
||
vdoNinjaConnectBtn.textContent = 'Disconnect';
|
||
vdoNinjaConnectBtn.classList.add('connected');
|
||
vdoNinjaConnectBtn.classList.remove('disconnected');
|
||
vdoNinjaConnectionStatus.textContent = 'Status: Connected';
|
||
vdoNinjaStatusIndicator.classList.add('connected');
|
||
} else {
|
||
vdoNinjaConnectBtn.textContent = 'Connect to VDO.Ninja';
|
||
vdoNinjaConnectBtn.classList.remove('connected');
|
||
vdoNinjaConnectBtn.classList.add('disconnected');
|
||
vdoNinjaConnectionStatus.textContent = 'Status: Disconnected';
|
||
vdoNinjaStatusIndicator.classList.remove('connected');
|
||
}
|
||
|
||
// Toggle input fields based on connection state
|
||
toggleVdoNinjaInputs(connected);
|
||
}
|
||
// Save/load settings from localStorage
|
||
function saveSettings() {
|
||
const settings = {
|
||
obsWsUrl: obsWsUrlInput.value,
|
||
obsWsPassword: obsWsPasswordInput.value,
|
||
vdoNinjaRoom: vdoNinjaRoomInput.value,
|
||
vdoNinjaPassword: vdoNinjaPasswordInput.value,
|
||
vdoNinjaStreamIds: vdoNinjaStreamIdsInput.value,
|
||
obsSceneName: obsSceneNameInput.value,
|
||
sourceSizing: sourceSizingSelect.value,
|
||
autoAddSources: autoAddSourcesCheckbox.checked,
|
||
autoRemoveSources: autoRemoveSourcesCheckbox.checked,
|
||
vdoNinjaConnected: vdoNinjaConnected
|
||
};
|
||
localStorage.setItem('obsNinjaSettings', JSON.stringify(settings));
|
||
|
||
// Save stream mappings separately
|
||
const mappings = getStreamMappings();
|
||
localStorage.setItem('obsNinjaStreamMappings', JSON.stringify(mappings));
|
||
|
||
// Save enable mapping setting
|
||
const enableStreamMappingCheckbox = document.getElementById('enableStreamMapping');
|
||
localStorage.setItem('obsNinjaEnableStreamMapping', enableStreamMappingCheckbox.checked);
|
||
}
|
||
|
||
function calculateGridPositions(totalSources, canvasWidth, canvasHeight) {
|
||
const positions = [];
|
||
|
||
// Determine grid dimensions (trying to make it as square as possible)
|
||
let cols = Math.ceil(Math.sqrt(totalSources));
|
||
let rows = Math.ceil(totalSources / cols);
|
||
|
||
// Calculate cell size
|
||
const cellWidth = canvasWidth / cols;
|
||
const cellHeight = canvasHeight / rows;
|
||
|
||
// Calculate center offset for incomplete rows
|
||
const lastRowItems = totalSources - ((rows - 1) * cols);
|
||
const lastRowOffset = (cols - lastRowItems) * (cellWidth / 2);
|
||
|
||
// Calculate positions for each cell
|
||
for (let row = 0; row < rows; row++) {
|
||
for (let col = 0; col < cols; col++) {
|
||
const index = row * cols + col;
|
||
if (index < totalSources) {
|
||
let xPos = col * cellWidth;
|
||
|
||
// Center items in the last row if it's not full
|
||
if (row === rows - 1 && lastRowItems < cols) {
|
||
xPos += lastRowOffset;
|
||
}
|
||
|
||
positions.push({
|
||
x: xPos,
|
||
y: row * cellHeight,
|
||
width: cellWidth,
|
||
height: cellHeight
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return positions;
|
||
}
|
||
function disconnectFromVdoNinja() {
|
||
vdoNinjaIframe.src = 'about:blank'; // Clear iframe
|
||
|
||
// Clear any pending connection check
|
||
if (vdoNinjaConnectionCheckTimer) {
|
||
clearTimeout(vdoNinjaConnectionCheckTimer);
|
||
vdoNinjaConnectionCheckTimer = null;
|
||
}
|
||
|
||
// Clear active streams
|
||
activeStreams = {};
|
||
updateStreamList();
|
||
|
||
// Update button state
|
||
updateVdoNinjaButtonState(false);
|
||
|
||
saveSettings();
|
||
}
|
||
|
||
function connectToVdoNinja() {
|
||
const room = vdoNinjaRoomInput.value.trim();
|
||
const streamIds = vdoNinjaStreamIdsInput.value.trim();
|
||
|
||
if (!room && !streamIds) {
|
||
logMessage("VDO.Ninja Error: Room Name or Stream ID(s) must be provided.");
|
||
return;
|
||
}
|
||
|
||
initializeVdoNinjaIframe();
|
||
|
||
// Set the status to connecting
|
||
vdoNinjaConnectionStatus.textContent = 'Status: Connecting...';
|
||
vdoNinjaConnectBtn.textContent = 'Cancel';
|
||
|
||
// Start a timeout to check for actual connection
|
||
if (vdoNinjaConnectionCheckTimer) {
|
||
clearTimeout(vdoNinjaConnectionCheckTimer);
|
||
}
|
||
|
||
vdoNinjaConnectionCheckTimer = setTimeout(() => {
|
||
// If we didn't receive any activity in 10 seconds, consider connection failed
|
||
if (Date.now() - vdoNinjaLastActivityTime > 10000) {
|
||
logMessage("VDO.Ninja connection timed out. No activity received.");
|
||
vdoNinjaConnectionStatus.textContent = 'Status: Connection Failed';
|
||
vdoNinjaConnectBtn.textContent = 'Connect to VDO.Ninja';
|
||
|
||
// Don't update to connected state
|
||
}
|
||
}, 10000);
|
||
|
||
// Disable inputs while connecting
|
||
toggleVdoNinjaInputs(true);
|
||
|
||
saveSettings();
|
||
}
|
||
|
||
function addNewStreamMapping(streamId = '', sceneName = '') {
|
||
const streamMappings = document.getElementById('streamMappings');
|
||
const mappingDiv = document.createElement('div');
|
||
mappingDiv.className = 'stream-mapping flex-row';
|
||
mappingDiv.style.margin = '5px 0';
|
||
mappingDiv.innerHTML = `
|
||
<input type="text" placeholder="Stream ID" value="${streamId}" class="mapping-stream-id" style="width:calc(40% - 4px);">
|
||
<input type="text" placeholder="Scene Name" value="${sceneName}" class="mapping-scene-name" style="width:calc(40% - 4px);">
|
||
<button class="remove-mapping-btn" style="width:auto; padding:3px 5px; margin:0;">×</button>
|
||
`;
|
||
|
||
streamMappings.appendChild(mappingDiv);
|
||
|
||
// Add event listener to remove button
|
||
const removeBtn = mappingDiv.querySelector('.remove-mapping-btn');
|
||
removeBtn.addEventListener('click', () => {
|
||
mappingDiv.remove();
|
||
saveSettings();
|
||
});
|
||
|
||
// Add event listeners to inputs
|
||
const inputs = mappingDiv.querySelectorAll('input');
|
||
inputs.forEach(input => {
|
||
input.addEventListener('change', saveSettings);
|
||
});
|
||
}
|
||
|
||
function setupStreamMappingUI() {
|
||
// Get references to new elements
|
||
const enableStreamMappingCheckbox = document.getElementById('enableStreamMapping');
|
||
const streamMappingContainer = document.getElementById('streamMappingContainer');
|
||
const addStreamMappingBtn = document.getElementById('addStreamMappingBtn');
|
||
|
||
// Event listeners
|
||
enableStreamMappingCheckbox.addEventListener('change', () => {
|
||
streamMappingContainer.style.display = enableStreamMappingCheckbox.checked ? 'block' : 'none';
|
||
saveSettings();
|
||
});
|
||
|
||
addStreamMappingBtn.addEventListener('click', addNewStreamMapping);
|
||
|
||
// Load saved mappings
|
||
loadStreamMappings();
|
||
}
|
||
|
||
function loadSettings() {
|
||
const settingsJson = localStorage.getItem('obsNinjaSettings');
|
||
if (settingsJson) {
|
||
try {
|
||
const settings = JSON.parse(settingsJson);
|
||
obsWsUrlInput.value = settings.obsWsUrl || '';
|
||
obsWsPasswordInput.value = settings.obsWsPassword || '';
|
||
vdoNinjaRoomInput.value = settings.vdoNinjaRoom || '';
|
||
vdoNinjaPasswordInput.value = settings.vdoNinjaPassword || '';
|
||
vdoNinjaStreamIdsInput.value = settings.vdoNinjaStreamIds || '';
|
||
obsSceneNameInput.value = settings.obsSceneName || '';
|
||
sourceSizingSelect.value = settings.sourceSizing || 'autoGrid'; // Changed default to autoGrid
|
||
autoAddSourcesCheckbox.checked = settings.autoAddSources !== false;
|
||
autoRemoveSourcesCheckbox.checked = settings.autoRemoveSources !== false;
|
||
|
||
// If VDO.Ninja was previously connected, reconnect
|
||
if (settings.vdoNinjaConnected) {
|
||
// Don't auto-connect, let user decide
|
||
// connectToVdoNinja();
|
||
}
|
||
} catch (e) {
|
||
logMessage(`Error loading settings: ${e.message}`);
|
||
localStorage.removeItem('obsNinjaSettings');
|
||
}
|
||
} else {
|
||
// If no settings found, set default to autoGrid
|
||
sourceSizingSelect.value = 'autoGrid';
|
||
}
|
||
|
||
// Initialize stream mapping UI
|
||
setupStreamMappingUI();
|
||
}
|
||
|
||
function updateStreamList() {
|
||
console.log("Updating stream list with:", activeStreams);
|
||
|
||
if (Object.keys(activeStreams).length === 0) {
|
||
streamListContainer.innerHTML = '<div class="stream-item">No active streams</div>';
|
||
return;
|
||
}
|
||
|
||
streamListContainer.innerHTML = '';
|
||
for (const streamId in activeStreams) {
|
||
const stream = activeStreams[streamId];
|
||
const streamDiv = document.createElement('div');
|
||
streamDiv.className = 'stream-item';
|
||
streamDiv.innerHTML = `
|
||
<div>${stream.label || streamId}</div>
|
||
<small>ID: ${streamId}</small>
|
||
${stream.sourceCreated ? '<span style="color:#4CAF50"> ✓ Added to OBS</span>' : ''}
|
||
<button class="add-stream-btn" data-stream-id="${streamId}">Add to OBS</button>
|
||
`;
|
||
streamListContainer.appendChild(streamDiv);
|
||
|
||
// Add event listener to the button we just created
|
||
const btn = streamDiv.querySelector('.add-stream-btn');
|
||
btn.addEventListener('click', () => {
|
||
addStreamToObs(streamId, stream.label);
|
||
});
|
||
}
|
||
|
||
// Log the updated DOM for debugging
|
||
console.log("Stream list updated:", streamListContainer.innerHTML);
|
||
}
|
||
|
||
obsConnectBtn.addEventListener('click', () => {
|
||
if (obsConnected && obs) {
|
||
// If already connected, disconnect
|
||
logMessage("Disconnecting from OBS WebSocket...");
|
||
if (obs) {
|
||
obs.close();
|
||
obs = null;
|
||
}
|
||
// onclose handler will clean up state
|
||
} else {
|
||
// Connect logic
|
||
connectToOBS();
|
||
}
|
||
});
|
||
|
||
function getTargetSceneForStream(streamId) {
|
||
// If stream mapping is enabled, check for a specific mapping
|
||
const enableStreamMappingCheckbox = document.getElementById('enableStreamMapping');
|
||
if (enableStreamMappingCheckbox.checked) {
|
||
const mappings = getStreamMappings();
|
||
for (const mapping of mappings) {
|
||
if (mapping.streamId === streamId) {
|
||
return mapping.sceneName;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fall back to the default scene
|
||
return getTargetScene();
|
||
}
|
||
|
||
function loadStreamMappings() {
|
||
const settingsJson = localStorage.getItem('obsNinjaStreamMappings');
|
||
if (settingsJson) {
|
||
try {
|
||
const mappings = JSON.parse(settingsJson);
|
||
for (const mapping of mappings) {
|
||
addNewStreamMapping(mapping.streamId, mapping.sceneName);
|
||
}
|
||
} catch (e) {
|
||
logMessage(`Error loading stream mappings: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
// Load enableStreamMapping setting
|
||
const enableMapping = localStorage.getItem('obsNinjaEnableStreamMapping') === 'true';
|
||
const enableStreamMappingCheckbox = document.getElementById('enableStreamMapping');
|
||
enableStreamMappingCheckbox.checked = enableMapping;
|
||
|
||
// Show/hide container based on checkbox
|
||
const streamMappingContainer = document.getElementById('streamMappingContainer');
|
||
streamMappingContainer.style.display = enableMapping ? 'block' : 'none';
|
||
}
|
||
function getStreamMappings() {
|
||
const mappings = [];
|
||
const mappingDivs = document.querySelectorAll('.stream-mapping');
|
||
|
||
mappingDivs.forEach(div => {
|
||
const streamId = div.querySelector('.mapping-stream-id').value.trim();
|
||
const sceneName = div.querySelector('.mapping-scene-name').value.trim();
|
||
|
||
if (streamId && sceneName) {
|
||
mappings.push({ streamId, sceneName });
|
||
}
|
||
});
|
||
|
||
return mappings;
|
||
}
|
||
|
||
|
||
async function connectToOBS() {
|
||
// Get and validate URL
|
||
let url = obsWsUrlInput.value.trim();
|
||
const password = obsWsPasswordInput.value;
|
||
|
||
if (!url) {
|
||
logMessage("Error: OBS WebSocket URL is required.");
|
||
return;
|
||
}
|
||
|
||
// Make sure URL starts with ws:// or wss://
|
||
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
|
||
url = 'ws://' + url;
|
||
obsWsUrlInput.value = url; // Update the input field with normalized URL
|
||
}
|
||
|
||
// Show connecting status
|
||
obsConnectionStatus.textContent = 'Status: Connecting...';
|
||
obsStatusIndicator.classList.remove('connected');
|
||
obsStatusIndicator.classList.remove('error');
|
||
|
||
logMessage(`Attempting to connect to OBS at ${url}...`);
|
||
|
||
// Setup connection timeout
|
||
const connectionTimeoutId = setTimeout(() => {
|
||
if (obs && obs.readyState !== WebSocket.OPEN) {
|
||
logMessage("Connection attempt timed out");
|
||
if (obs) {
|
||
try {
|
||
obs.close();
|
||
} catch (e) {
|
||
// Ignore close errors
|
||
}
|
||
obs = null;
|
||
}
|
||
obsConnectionStatus.textContent = 'Status: Error - Connection timed out';
|
||
obsStatusIndicator.classList.add('error');
|
||
}
|
||
}, 10000); // 10 second timeout
|
||
|
||
try {
|
||
// Create new WebSocket connection
|
||
obs = new WebSocket(url);
|
||
|
||
// Set up WebSocket event handlers
|
||
obs.onopen = () => {
|
||
logMessage("WebSocket connection opened. Waiting for Hello message...");
|
||
// The actual authentication will happen when we receive the Hello message in onmessage
|
||
};
|
||
|
||
obs.onmessage = async (event) => {
|
||
try {
|
||
const message = JSON.parse(event.data);
|
||
|
||
if (message.op === 0) { // Hello
|
||
logMessage("Received Hello from OBS WebSocket server");
|
||
|
||
// Display the raw message for debugging
|
||
logMessage(JSON.stringify(message));
|
||
|
||
try {
|
||
// Prepare Identify message
|
||
const identifyPayload = {
|
||
op: 1, // Identify
|
||
d: {
|
||
rpcVersion: 1,
|
||
eventSubscriptions: (1 << 0) | (1 << 1), // General and Config events
|
||
}
|
||
};
|
||
|
||
// Add authentication if required
|
||
if (message.d && message.d.authentication) {
|
||
const challenge = message.d.authentication.challenge;
|
||
const salt = message.d.authentication.salt;
|
||
|
||
if (password) {
|
||
const authResponse = await generateAuthResponse(password, salt, challenge);
|
||
identifyPayload.d.authentication = authResponse;
|
||
logMessage("Authentication data prepared");
|
||
} else {
|
||
logMessage("Warning: Server requires authentication but no password provided");
|
||
}
|
||
}
|
||
|
||
// Send Identify message
|
||
logMessage("Sending Identify message to OBS WebSocket server");
|
||
logMessage(JSON.stringify(identifyPayload));
|
||
obs.send(JSON.stringify(identifyPayload));
|
||
} catch (error) {
|
||
logMessage(`Error during authentication: ${error.message}`);
|
||
if (obs) {
|
||
obs.close();
|
||
}
|
||
}
|
||
} else if (message.op === 2) { // Identified (auth success)
|
||
clearTimeout(connectionTimeoutId); // Clear the timeout since we're now connected
|
||
|
||
logMessage("OBS Authentication successful!");
|
||
obsConnected = true;
|
||
obsConnectBtn.textContent = 'Disconnect';
|
||
obsConnectBtn.classList.add('connected');
|
||
obsConnectBtn.classList.remove('disconnected');
|
||
obsConnectionStatus.textContent = 'Status: Connected';
|
||
obsStatusIndicator.classList.add('connected');
|
||
|
||
onObsConnected();
|
||
} else if (message.op === 7) { // RequestResponse
|
||
// Process the response
|
||
if (message.d && message.d.requestId && requestCallbacks[message.d.requestId]) {
|
||
requestCallbacks[message.d.requestId](message.d);
|
||
delete requestCallbacks[message.d.requestId];
|
||
}
|
||
|
||
if (message.d && message.d.requestStatus && message.d.requestStatus.code !== 100) { // 100 is success
|
||
logMessage(`OBS Request Error (${message.d.requestType || 'Unknown'}): ${message.d.requestStatus.comment || 'Unknown error'}`);
|
||
}
|
||
} else if (message.op === 5) { // Event
|
||
// Handle events as needed
|
||
logMessage(`Received event: ${message.d ? message.d.eventType : 'Unknown'}`);
|
||
} else {
|
||
logMessage(`Received message with opcode ${message.op}: ${JSON.stringify(message)}`);
|
||
}
|
||
} catch (error) {
|
||
logMessage(`Error processing WebSocket message: ${error.message}`);
|
||
}
|
||
};
|
||
|
||
obs.onerror = (error) => {
|
||
clearTimeout(connectionTimeoutId);
|
||
logMessage(`OBS WebSocket Error: ${error.message || 'Unknown WebSocket error'}`);
|
||
obsStatusIndicator.classList.add('error');
|
||
obsConnectionStatus.textContent = 'Status: Error';
|
||
};
|
||
|
||
obs.onclose = (event) => {
|
||
clearTimeout(connectionTimeoutId);
|
||
|
||
let closeReason = '';
|
||
|
||
// Handle specific close codes
|
||
if (event.code) {
|
||
switch (event.code) {
|
||
case 1000:
|
||
closeReason = 'Normal closure';
|
||
break;
|
||
case 1006:
|
||
closeReason = 'Abnormal closure - connection might have timed out or failed';
|
||
break;
|
||
case 4009:
|
||
closeReason = 'Authentication Failed - incorrect password';
|
||
break;
|
||
case 4010:
|
||
closeReason = 'Authentication Failed - outdated plugin protocol';
|
||
break;
|
||
default:
|
||
closeReason = `Close code: ${event.code}`;
|
||
}
|
||
}
|
||
|
||
logMessage(`OBS WebSocket Connection Closed. ${closeReason}`);
|
||
obsConnected = false;
|
||
obsConnectBtn.textContent = 'Connect to OBS';
|
||
obsConnectBtn.classList.remove('connected');
|
||
obsConnectBtn.classList.add('disconnected');
|
||
obsConnectionStatus.textContent = 'Status: Disconnected';
|
||
obsStatusIndicator.classList.remove('connected');
|
||
obsStatusIndicator.classList.remove('error');
|
||
|
||
onObsDisconnected();
|
||
};
|
||
|
||
} catch (error) {
|
||
clearTimeout(connectionTimeoutId);
|
||
logMessage(`Error creating WebSocket connection: ${error.message}`);
|
||
obsConnectionStatus.textContent = 'Status: Error';
|
||
obsStatusIndicator.classList.add('error');
|
||
}
|
||
}
|
||
|
||
|
||
// Helper function to handle successful connection
|
||
function handleSuccessfulConnection() {
|
||
obsConnected = true;
|
||
obsConnectBtn.textContent = 'Disconnect from OBS';
|
||
obsConnectBtn.classList.add('connected');
|
||
obsConnectBtn.classList.remove('disconnected');
|
||
obsConnectionStatus.textContent = 'Status: Connected';
|
||
obsStatusIndicator.classList.add('connected');
|
||
onObsConnected();
|
||
}
|
||
// Helper function to handle request responses
|
||
function handleRequestResponse(message) {
|
||
if (message.d.requestId && requestCallbacks[message.d.requestId]) {
|
||
requestCallbacks[message.d.requestId](message.d);
|
||
delete requestCallbacks[message.d.requestId];
|
||
}
|
||
|
||
if (message.d.requestStatus && message.d.requestStatus.code !== 100) { // 100 is success
|
||
logMessage(`OBS Request Error (${message.d.requestType}): ${message.d.requestStatus.comment || 'Unknown error'}`);
|
||
}
|
||
}
|
||
async function handleHelloMessage(message) {
|
||
try {
|
||
const password = obsWsPasswordInput.value;
|
||
// Prepare Identify message
|
||
const identifyPayload = {
|
||
op: 1, // Identify
|
||
d: {
|
||
rpcVersion: 1,
|
||
eventSubscriptions: (1 << 0) | (1 << 1), // General and Config events
|
||
}
|
||
};
|
||
|
||
// Add authentication if required
|
||
if (message.d.authentication) {
|
||
const challenge = message.d.authentication.challenge;
|
||
const salt = message.d.authentication.salt;
|
||
|
||
if (password) {
|
||
identifyPayload.d.authentication = await generateAuthResponse(password, salt, challenge);
|
||
logMessage("Authentication data prepared");
|
||
} else {
|
||
logMessage("Warning: Server requires authentication but no password provided");
|
||
}
|
||
}
|
||
|
||
// Send Identify message
|
||
logMessage("Sending Identify message to OBS WebSocket server");
|
||
obs.send(JSON.stringify(identifyPayload));
|
||
} catch (error) {
|
||
logMessage(`Error during authentication: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function generateAuthResponse(password, salt, challenge) {
|
||
try {
|
||
// Create TextEncoder
|
||
const encoder = new TextEncoder();
|
||
|
||
// Step 1: Generate secret hash using SHA-256
|
||
const secretString = password + salt;
|
||
const secretData = encoder.encode(secretString);
|
||
|
||
let secretHash;
|
||
try {
|
||
// Try using Web Crypto API first
|
||
if (window.crypto && window.crypto.subtle) {
|
||
const hashBuffer = await window.crypto.subtle.digest('SHA-256', secretData);
|
||
secretHash = new Uint8Array(hashBuffer);
|
||
} else {
|
||
throw new Error("Web Crypto not available");
|
||
}
|
||
} catch (e) {
|
||
// Fallback to jsSHA library
|
||
logMessage("Using jsSHA fallback for auth");
|
||
await loadJsShaLibrary();
|
||
const shaObj = new jsSHA("SHA-256", "TEXT", { encoding: "UTF8" });
|
||
shaObj.update(secretString);
|
||
const hashHex = shaObj.getHash("HEX");
|
||
secretHash = new Uint8Array(hashHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
|
||
}
|
||
|
||
// Convert to Base64
|
||
const secretBase64 = btoa(String.fromCharCode.apply(null, secretHash));
|
||
|
||
// Step 2: Generate auth response
|
||
const authString = secretBase64 + challenge;
|
||
const authData = encoder.encode(authString);
|
||
|
||
let authHash;
|
||
try {
|
||
// Try using Web Crypto API first
|
||
if (window.crypto && window.crypto.subtle) {
|
||
const hashBuffer = await window.crypto.subtle.digest('SHA-256', authData);
|
||
authHash = new Uint8Array(hashBuffer);
|
||
} else {
|
||
throw new Error("Web Crypto not available");
|
||
}
|
||
} catch (e) {
|
||
// Fallback to jsSHA library
|
||
const shaObj = new jsSHA("SHA-256", "TEXT", { encoding: "UTF8" });
|
||
shaObj.update(authString);
|
||
const hashHex = shaObj.getHash("HEX");
|
||
authHash = new Uint8Array(hashHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
|
||
}
|
||
|
||
// Convert to Base64
|
||
return btoa(String.fromCharCode.apply(null, authHash));
|
||
} catch (error) {
|
||
logMessage(`Auth error: ${error.message}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Helper function to send OBS WebSocket requests
|
||
function sendRequest(requestType, requestData = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
if (!obsConnected || !obs) {
|
||
reject(new Error("Not connected to OBS"));
|
||
return;
|
||
}
|
||
|
||
const requestId = generateRequestId(requestType);
|
||
|
||
// Register callback for this request
|
||
requestCallbacks[requestId] = (response) => {
|
||
if (response.requestStatus.code === 100) {
|
||
resolve(response.responseData || {});
|
||
} else {
|
||
reject(new Error(`Request failed: ${response.requestStatus.comment}`));
|
||
}
|
||
};
|
||
|
||
// Send the request
|
||
const request = {
|
||
op: 6, // Request
|
||
d: {
|
||
requestType: requestType,
|
||
requestId: requestId,
|
||
requestData: requestData
|
||
}
|
||
};
|
||
|
||
try {
|
||
obs.send(JSON.stringify(request));
|
||
} catch (error) {
|
||
delete requestCallbacks[requestId];
|
||
reject(error);
|
||
}
|
||
|
||
// Set a timeout to reject the promise if we don't get a response
|
||
setTimeout(() => {
|
||
if (requestCallbacks[requestId]) {
|
||
delete requestCallbacks[requestId];
|
||
reject(new Error(`Request timeout for ${requestType}`));
|
||
}
|
||
}, 5000);
|
||
});
|
||
}
|
||
|
||
function onObsConnected() {
|
||
logMessage("OBS Connected. Fetching scenes and initializing VDO.Ninja iframe.");
|
||
fetchObsScenes();
|
||
|
||
// Apply grid layout if it's selected
|
||
if (sourceSizingSelect.value === 'autoGrid') {
|
||
setTimeout(rearrangeAllStreams, 1000); // Short delay to ensure scenes are loaded
|
||
}
|
||
|
||
// If VDO.Ninja was previously connected according to settings, reconnect
|
||
const settingsJson = localStorage.getItem('obsNinjaSettings');
|
||
if (settingsJson) {
|
||
try {
|
||
const settings = JSON.parse(settingsJson);
|
||
if (settings.vdoNinjaConnected &&
|
||
(settings.vdoNinjaRoom || settings.vdoNinjaStreamIds)) {
|
||
// Only connect if we have valid connection parameters
|
||
connectToVdoNinja();
|
||
}
|
||
} catch (e) {
|
||
logMessage(`Error loading VDO.Ninja connection settings: ${e.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function onObsDisconnected() {
|
||
logMessage("OBS Disconnected.");
|
||
|
||
// Note: We no longer automatically stop VDO.Ninja iframe here
|
||
// Only clear streams if we're connected to OBS
|
||
|
||
// Clear active OBS connections, but don't disconnect VDO.Ninja
|
||
// activeStreams remains intact but sourceCreated set to false
|
||
for (const streamId in activeStreams) {
|
||
activeStreams[streamId].sourceCreated = false;
|
||
}
|
||
updateStreamList();
|
||
}
|
||
|
||
async function fetchObsScenes() {
|
||
if (!obsConnected || !obs) return;
|
||
logMessage("Fetching OBS scenes...");
|
||
|
||
try {
|
||
const response = await sendRequest('GetSceneList');
|
||
if (response && response.scenes) {
|
||
obsScenes = response.scenes;
|
||
populateSceneDropdown(response.scenes);
|
||
}
|
||
} catch (error) {
|
||
logMessage(`Error fetching OBS scenes: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
function populateSceneDropdown(scenesData) {
|
||
obsSceneSelect.innerHTML = ''; // Clear existing options
|
||
|
||
if (!scenesData || scenesData.length === 0) {
|
||
logMessage("No scenes found or error in fetching.");
|
||
obsSceneSelect.style.display = 'none';
|
||
obsSceneNameInput.style.display = 'block'; // Fallback to text input
|
||
return;
|
||
}
|
||
|
||
scenesData.forEach(scene => {
|
||
const option = document.createElement('option');
|
||
option.value = scene.sceneName;
|
||
option.textContent = scene.sceneName;
|
||
obsSceneSelect.appendChild(option);
|
||
});
|
||
|
||
obsSceneSelect.style.display = 'block';
|
||
obsSceneNameInput.style.display = 'none'; // Hide text input if dropdown is populated
|
||
|
||
// Set the current scene as selected if it exists
|
||
if (obsSceneNameInput.value) {
|
||
const matchingOption = Array.from(obsSceneSelect.options)
|
||
.find(option => option.value === obsSceneNameInput.value);
|
||
|
||
if (matchingOption) {
|
||
obsSceneSelect.value = obsSceneNameInput.value;
|
||
}
|
||
}
|
||
|
||
logMessage("OBS Scenes dropdown populated.");
|
||
}
|
||
|
||
function getTargetScene() {
|
||
return obsSceneSelect.style.display !== 'none' ? obsSceneSelect.value : obsSceneNameInput.value.trim();
|
||
}
|
||
|
||
function getVdoNinjaViewUrl(streamId, includeCommonParams = true) {
|
||
const room = vdoNinjaRoomInput.value.trim();
|
||
const ninjaPassword = vdoNinjaPasswordInput.value;
|
||
|
||
// Start with the base URL
|
||
let url = "https://vdo.ninja/?";
|
||
|
||
// Handle different URL structures for room vs. non-room
|
||
if (room) {
|
||
// In a room, we need to use view+room with solo parameter
|
||
url += `view=${encodeURIComponent(streamId)}&solo&room=${encodeURIComponent(room)}`;
|
||
} else {
|
||
// Not in a room, just use view parameter
|
||
url += `view=${encodeURIComponent(streamId)}`;
|
||
}
|
||
|
||
// Add password if provided
|
||
if (ninjaPassword) {
|
||
url += `&password=${encodeURIComponent(ninjaPassword)}`;
|
||
}
|
||
|
||
// Add common parameters if requested
|
||
if (includeCommonParams) {
|
||
url += "&cleanoutput&proaudio&ab=160&transparent&autoplay&codec=h264";
|
||
}
|
||
|
||
return url;
|
||
}
|
||
|
||
function initializeVdoNinjaIframe() {
|
||
const room = vdoNinjaRoomInput.value.trim();
|
||
const streamIds = vdoNinjaStreamIdsInput.value.trim();
|
||
|
||
if (!room && !streamIds) {
|
||
logMessage("VDO.Ninja Error: Room Name or Stream ID(s) must be provided.");
|
||
return;
|
||
}
|
||
|
||
// Always use secure HTTPS connection
|
||
let vdoNinjaUrl = "https://vdo.ninja/?";
|
||
|
||
if (room) {
|
||
vdoNinjaUrl += `room=${encodeURIComponent(room)}`;
|
||
|
||
if (streamIds) {
|
||
// If in a room and viewing specific streams, use view+solo
|
||
const viewParam = streamIds.split(',').map(s => s.trim()).join(',');
|
||
vdoNinjaUrl += `&view=${encodeURIComponent(viewParam)}&solo`;
|
||
}
|
||
} else if (streamIds) {
|
||
// If not in a room, just use view
|
||
vdoNinjaUrl += `view=${encodeURIComponent(streamIds.split(',').map(s => s.trim()).join(','))}`;
|
||
}
|
||
|
||
if (vdoNinjaPasswordInput.value) {
|
||
vdoNinjaUrl += `&password=${encodeURIComponent(vdoNinjaPasswordInput.value)}`;
|
||
}
|
||
|
||
// Add parameters for data-only mode and other required VDO.Ninja flags
|
||
vdoNinjaUrl += "&cleanoutput&dataonly&nocursor&nopush&debug&noaudio&novideo";
|
||
|
||
// Add CORS attributes to make the iframe COEP-friendly
|
||
vdoNinjaUrl += "&cors="+encodeURIComponent(window.location.origin);
|
||
|
||
logMessage(`Loading VDO.Ninja iframe with URL: ${vdoNinjaUrl}`);
|
||
|
||
// Add crossorigin and other attributes that help with COEP
|
||
vdoNinjaIframe.src = 'about:blank'; // Clear first to reset any error state
|
||
vdoNinjaIframe.setAttribute('crossorigin', 'anonymous');
|
||
vdoNinjaIframe.setAttribute('allow', 'autoplay; camera; microphone; fullscreen; display-capture; clipboard-write');
|
||
vdoNinjaIframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups');
|
||
|
||
// Delay setting the source to allow attributes to apply
|
||
setTimeout(() => {
|
||
vdoNinjaIframe.src = vdoNinjaUrl;
|
||
}, 10);
|
||
|
||
// Save settings
|
||
saveSettings();
|
||
}
|
||
|
||
window.addEventListener("message", (event) => {
|
||
// Verify origin for security
|
||
if (!event.origin.startsWith("https://vdo.ninja")) {
|
||
return;
|
||
}
|
||
|
||
if (event.source !== vdoNinjaIframe.contentWindow) {
|
||
return;
|
||
}
|
||
|
||
const data = event.data;
|
||
|
||
// Update the last activity timestamp on any message
|
||
vdoNinjaLastActivityTime = Date.now();
|
||
|
||
// If we receive any message from VDO.Ninja and we're not marked as connected yet, update status
|
||
if (!vdoNinjaConnected) {
|
||
updateVdoNinjaButtonState(true);
|
||
|
||
// Clear any pending connection check
|
||
if (vdoNinjaConnectionCheckTimer) {
|
||
clearTimeout(vdoNinjaConnectionCheckTimer);
|
||
vdoNinjaConnectionCheckTimer = null;
|
||
}
|
||
|
||
logMessage("VDO.Ninja connection established (received message)");
|
||
}
|
||
|
||
// Log incoming messages for debugging
|
||
console.log("VDO.Ninja message received:", data);
|
||
|
||
if (data && (
|
||
data.action === "guest-connected" ||
|
||
data.action === "view-connection" ||
|
||
data.action === "push-connection" ||
|
||
data.action === "director-connected"
|
||
)) {
|
||
logMessage(`VDO.Ninja: ${JSON.stringify(data)}`);
|
||
}
|
||
|
||
// Process the connection events
|
||
if (data && data.streamID) {
|
||
if (data.action === "view-connection" && data.value === true) {
|
||
// Stream connected
|
||
logMessage(`New VDO.Ninja stream connected: ${data.streamID}. Label: Stream ${data.streamID}`);
|
||
|
||
activeStreams[data.streamID] = {
|
||
label: data.label || `Stream ${data.streamID}`,
|
||
sourceCreated: false,
|
||
streamId: data.streamID,
|
||
uuid: data.UUID || null,
|
||
connected: true
|
||
};
|
||
|
||
updateStreamList();
|
||
|
||
// Auto-add to OBS if enabled
|
||
if (autoAddSourcesCheckbox.checked) {
|
||
addStreamToObs(data.streamID, activeStreams[data.streamID].label);
|
||
}
|
||
}
|
||
else if ((data.action === "view-connection" && data.value === false) ||
|
||
(data.action === "push-connection" && data.value === false)) {
|
||
// Stream disconnected
|
||
if (activeStreams[data.streamID]) {
|
||
logMessage(`VDO.Ninja stream disconnected: ${data.streamID}`);
|
||
|
||
// Remove from OBS if auto-remove is enabled
|
||
if (autoRemoveSourcesCheckbox && autoRemoveSourcesCheckbox.checked) {
|
||
removeStreamFromObs(data.streamID);
|
||
}
|
||
|
||
delete activeStreams[data.streamID];
|
||
updateStreamList();
|
||
}
|
||
}
|
||
else if (data.action === "guest-connected" || (data.action === "push-connection" && data.value === true)) {
|
||
// Another type of connection
|
||
logMessage(`New VDO.Ninja stream connected: ${data.streamID}. Label: ${data.label || `Stream ${data.streamID}`}`);
|
||
|
||
activeStreams[data.streamID] = {
|
||
label: data.label || `Stream ${data.streamID}`,
|
||
sourceCreated: false,
|
||
streamId: data.streamID,
|
||
uuid: data.UUID || null,
|
||
connected: true
|
||
};
|
||
|
||
updateStreamList();
|
||
|
||
// Auto-add to OBS if enabled
|
||
if (autoAddSourcesCheckbox.checked) {
|
||
addStreamToObs(data.streamID, activeStreams[data.streamID].label);
|
||
}
|
||
}
|
||
} else if (data && data.action === "ping") {
|
||
// We got a ping message, good for keeping connection alive
|
||
console.log("VDO.Ninja ping received");
|
||
} else if (data && data.action === "initialize") {
|
||
// VDO.Ninja is telling us it's initialized
|
||
logMessage("VDO.Ninja iframe initialized");
|
||
}
|
||
}, false);
|
||
|
||
function startVdoNinjaConnectionMonitor() {
|
||
// Run every 30 seconds
|
||
setInterval(() => {
|
||
if (vdoNinjaConnected) {
|
||
// If we haven't received a message in 45 seconds, consider the connection dead
|
||
if (Date.now() - vdoNinjaLastActivityTime > 45000) {
|
||
logMessage("VDO.Ninja connection lost (no activity for 45 seconds)");
|
||
|
||
// Mark as disconnected but don't clear the iframe yet
|
||
// - user might want to reconnect
|
||
vdoNinjaConnectionStatus.textContent = 'Status: Connection Lost';
|
||
vdoNinjaStatusIndicator.classList.remove('connected');
|
||
|
||
// Show a reconnect button
|
||
vdoNinjaConnectBtn.textContent = 'Reconnect';
|
||
}
|
||
}
|
||
}, 30000);
|
||
}
|
||
|
||
async function addStreamToObs(streamId, streamLabel, targetSceneName = null) {
|
||
if (!obsConnected || !obs) {
|
||
logMessage("Cannot add stream to OBS: Not connected.");
|
||
return;
|
||
}
|
||
|
||
const targetScene = targetSceneName || getTargetScene();
|
||
if (!targetScene) {
|
||
logMessage("Cannot add stream to OBS: Target scene name is required.");
|
||
return;
|
||
}
|
||
|
||
const sourceName = `VDO.Ninja_${streamId}`;
|
||
|
||
// Use the helper function to get the correct URL
|
||
const vdoNinjaStreamUrl = getVdoNinjaViewUrl(streamId);
|
||
|
||
// Get OBS canvas size first to properly size the source
|
||
let canvasWidth = 1920;
|
||
let canvasHeight = 1080;
|
||
|
||
try {
|
||
const videoSettings = await sendRequest('GetVideoSettings');
|
||
if (videoSettings && videoSettings.baseWidth && videoSettings.baseHeight) {
|
||
canvasWidth = videoSettings.baseWidth;
|
||
canvasHeight = videoSettings.baseHeight;
|
||
}
|
||
} catch (error) {
|
||
logMessage(`Could not get canvas size, using default 1920x1080: ${error.message}`);
|
||
}
|
||
|
||
const inputSettings = {
|
||
url: vdoNinjaStreamUrl,
|
||
width: canvasWidth,
|
||
height: canvasHeight,
|
||
fps: 30,
|
||
reroute_audio: true, // This ensures audio is captured by OBS
|
||
restart_when_active: true,
|
||
shutdown: true
|
||
};
|
||
|
||
logMessage(`Adding browser source '${sourceName}' to scene '${targetScene}' (${canvasWidth}x${canvasHeight})`);
|
||
|
||
try {
|
||
// Check if source already exists
|
||
let sourceExists = false;
|
||
let sceneItemId = null;
|
||
|
||
try {
|
||
const sceneItems = await sendRequest('GetSceneItemList', { sceneName: targetScene });
|
||
|
||
for (const item of sceneItems.sceneItems || []) {
|
||
if (item.sourceName === sourceName) {
|
||
sourceExists = true;
|
||
sceneItemId = item.sceneItemId;
|
||
break;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logMessage(`Error checking for existing source: ${error.message}`);
|
||
}
|
||
|
||
// Create or update the source
|
||
if (!sourceExists) {
|
||
try {
|
||
const createResponse = await sendRequest('CreateInput', {
|
||
sceneName: targetScene,
|
||
inputName: sourceName,
|
||
inputKind: 'browser_source',
|
||
inputSettings: inputSettings,
|
||
sceneItemEnabled: true
|
||
});
|
||
|
||
sceneItemId = createResponse.sceneItemId;
|
||
logMessage(`Browser source '${sourceName}' created successfully. Item ID: ${sceneItemId}`);
|
||
|
||
if (activeStreams[streamId]) {
|
||
activeStreams[streamId].sourceCreated = true;
|
||
updateStreamList();
|
||
}
|
||
} catch (error) {
|
||
if (error.message.includes("name already in use") || error.message.includes("already exists")) {
|
||
logMessage(`Source '${sourceName}' already exists. Attempting to update its settings.`);
|
||
sourceExists = true;
|
||
} else {
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (sourceExists) {
|
||
await sendRequest('SetInputSettings', {
|
||
inputName: sourceName,
|
||
inputSettings: {
|
||
url: vdoNinjaStreamUrl,
|
||
reroute_audio: true,
|
||
width: canvasWidth,
|
||
height: canvasHeight
|
||
}
|
||
});
|
||
|
||
logMessage(`Updated settings for existing source '${sourceName}'`);
|
||
|
||
if (!sceneItemId) {
|
||
try {
|
||
const itemInfo = await sendRequest('GetSceneItemId', {
|
||
sceneName: targetScene,
|
||
sourceName: sourceName
|
||
});
|
||
sceneItemId = itemInfo.sceneItemId;
|
||
} catch (error) {
|
||
logMessage(`Error getting scene item ID: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
if (activeStreams[streamId]) {
|
||
activeStreams[streamId].sourceCreated = true;
|
||
updateStreamList();
|
||
}
|
||
}
|
||
|
||
// If we're in auto grid mode, rearrange all streams
|
||
if (sourceSizingSelect.value === 'autoGrid') {
|
||
await rearrangeAllStreams();
|
||
} else if (sceneItemId) {
|
||
// Otherwise just apply the individual transform
|
||
const transform = calculateTransform(
|
||
sourceSizingSelect.value,
|
||
canvasWidth,
|
||
canvasHeight,
|
||
canvasWidth,
|
||
canvasHeight
|
||
);
|
||
|
||
await sendRequest('SetSceneItemTransform', {
|
||
sceneName: targetScene,
|
||
sceneItemId: sceneItemId,
|
||
sceneItemTransform: transform
|
||
});
|
||
|
||
logMessage(`Applied transform to source '${sourceName}'`);
|
||
}
|
||
|
||
} catch (error) {
|
||
logMessage(`Error adding stream to OBS: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
function calculateTransform(sizingMode, sourceWidth, sourceHeight, canvasWidth, canvasHeight, gridPosition = null) {
|
||
let transform = {
|
||
alignment: 5, // Center alignment
|
||
boundsType: "OBS_BOUNDS_NONE",
|
||
boundsAlignment: 0,
|
||
boundsWidth: sourceWidth,
|
||
boundsHeight: sourceHeight,
|
||
positionX: 0,
|
||
positionY: 0,
|
||
scaleX: 1.0,
|
||
scaleY: 1.0,
|
||
rotation: 0.0,
|
||
cropTop: 0,
|
||
cropBottom: 0,
|
||
cropLeft: 0,
|
||
cropRight: 0,
|
||
sourceWidth: sourceWidth,
|
||
sourceHeight: sourceHeight,
|
||
width: sourceWidth,
|
||
height: sourceHeight
|
||
};
|
||
|
||
switch (sizingMode) {
|
||
case 'stretchToFill':
|
||
transform.boundsType = "OBS_BOUNDS_STRETCH";
|
||
transform.boundsWidth = canvasWidth;
|
||
transform.boundsHeight = canvasHeight;
|
||
transform.width = canvasWidth;
|
||
transform.height = canvasHeight;
|
||
break;
|
||
|
||
case 'bestFit':
|
||
transform.boundsType = "OBS_BOUNDS_SCALE_INNER";
|
||
transform.boundsWidth = canvasWidth;
|
||
transform.boundsHeight = canvasHeight;
|
||
transform.width = canvasWidth;
|
||
transform.height = canvasHeight;
|
||
break;
|
||
|
||
case 'autoGrid':
|
||
if (gridPosition) {
|
||
transform.boundsType = "OBS_BOUNDS_SCALE_INNER";
|
||
transform.positionX = gridPosition.x;
|
||
transform.positionY = gridPosition.y;
|
||
transform.boundsWidth = gridPosition.width;
|
||
transform.boundsHeight = gridPosition.height;
|
||
transform.width = gridPosition.width;
|
||
transform.height = gridPosition.height;
|
||
} else {
|
||
// Fall back to bestFit if no grid position
|
||
transform.boundsType = "OBS_BOUNDS_SCALE_INNER";
|
||
transform.boundsWidth = canvasWidth;
|
||
transform.boundsHeight = canvasHeight;
|
||
transform.width = canvasWidth;
|
||
transform.height = canvasHeight;
|
||
}
|
||
break;
|
||
|
||
case 'defaultSize':
|
||
default:
|
||
transform.boundsType = "OBS_BOUNDS_NONE";
|
||
transform.width = sourceWidth;
|
||
transform.height = sourceHeight;
|
||
// Position in center by default
|
||
transform.positionX = (canvasWidth - sourceWidth) / 2;
|
||
transform.positionY = (canvasHeight - sourceHeight) / 2;
|
||
break;
|
||
}
|
||
|
||
return transform;
|
||
}
|
||
|
||
async function rearrangeAllStreams() {
|
||
if (!obsConnected || !obs || sourceSizingSelect.value !== 'autoGrid') {
|
||
return; // Only rearrange if auto grid is selected
|
||
}
|
||
|
||
const targetScene = getTargetScene();
|
||
if (!targetScene) {
|
||
logMessage("Cannot rearrange streams: Target scene name is required.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Get OBS canvas size
|
||
let canvasWidth = 1920;
|
||
let canvasHeight = 1080;
|
||
|
||
try {
|
||
const videoSettings = await sendRequest('GetVideoSettings');
|
||
if (videoSettings && videoSettings.baseWidth && videoSettings.baseHeight) {
|
||
canvasWidth = videoSettings.baseWidth;
|
||
canvasHeight = videoSettings.baseHeight;
|
||
}
|
||
} catch (error) {
|
||
logMessage(`Could not get canvas size, using default 1920x1080: ${error.message}`);
|
||
}
|
||
|
||
// Get all active streams that have been added to OBS
|
||
const activeStreamIds = Object.keys(activeStreams).filter(
|
||
streamId => activeStreams[streamId].sourceCreated
|
||
);
|
||
|
||
if (activeStreamIds.length === 0) {
|
||
return; // No streams to arrange
|
||
}
|
||
|
||
// Calculate grid positions
|
||
const positions = calculateGridPositions(activeStreamIds.length, canvasWidth, canvasHeight);
|
||
|
||
// Get all scene items
|
||
const sceneItems = await sendRequest('GetSceneItemList', { sceneName: targetScene });
|
||
|
||
// Update each stream's position
|
||
for (let i = 0; i < activeStreamIds.length; i++) {
|
||
const streamId = activeStreamIds[i];
|
||
const sourceName = `VDO.Ninja_${streamId}`;
|
||
|
||
// Find the scene item for this source
|
||
const item = sceneItems.sceneItems.find(item => item.sourceName === sourceName);
|
||
if (!item) continue;
|
||
|
||
// Apply transform with grid position
|
||
const transform = calculateTransform(
|
||
'autoGrid',
|
||
canvasWidth,
|
||
canvasHeight,
|
||
canvasWidth,
|
||
canvasHeight,
|
||
positions[i]
|
||
);
|
||
|
||
await sendRequest('SetSceneItemTransform', {
|
||
sceneName: targetScene,
|
||
sceneItemId: item.sceneItemId,
|
||
sceneItemTransform: transform
|
||
});
|
||
|
||
logMessage(`Repositioned source '${sourceName}' in grid position ${i+1} of ${activeStreamIds.length}`);
|
||
}
|
||
} catch (error) {
|
||
logMessage(`Error rearranging streams: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function removeStreamFromObs(streamId) {
|
||
if (!obsConnected || !obs) return;
|
||
|
||
const sourceName = `VDO.Ninja_${streamId}`;
|
||
const targetScene = getTargetScene();
|
||
|
||
if (!targetScene) return;
|
||
|
||
logMessage(`Attempting to remove source '${sourceName}' from scene '${targetScene}'`);
|
||
|
||
try {
|
||
// Get the scene item ID
|
||
const itemInfo = await sendRequest('GetSceneItemId', {
|
||
sceneName: targetScene,
|
||
sourceName: sourceName
|
||
});
|
||
|
||
if (itemInfo && itemInfo.sceneItemId) {
|
||
// Remove the scene item
|
||
await sendRequest('RemoveSceneItem', {
|
||
sceneName: targetScene,
|
||
sceneItemId: itemInfo.sceneItemId
|
||
});
|
||
|
||
logMessage(`Removed source '${sourceName}' from scene '${targetScene}'`);
|
||
|
||
// If we're in auto grid mode, rearrange remaining streams
|
||
if (sourceSizingSelect.value === 'autoGrid') {
|
||
// Small delay to ensure the removal is complete
|
||
setTimeout(rearrangeAllStreams, 100);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// If not found, that's fine - otherwise log the error
|
||
if (!error.message.includes("not found")) {
|
||
logMessage(`Error removing source '${sourceName}': ${error.message}`);
|
||
}
|
||
}
|
||
}
|
||
function loadJsShaLibrary() {
|
||
return new Promise((resolve, reject) => {
|
||
if (typeof jsSHA !== 'undefined') {
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
const script = document.createElement('script');
|
||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jsSHA/3.3.0/sha256.js';
|
||
script.onload = resolve;
|
||
script.onerror = () => reject(new Error('Failed to load jsSHA library'));
|
||
document.head.appendChild(script);
|
||
});
|
||
}
|
||
|
||
|
||
// Event handlers for form changes
|
||
obsSceneSelect.addEventListener('change', () => {
|
||
obsSceneNameInput.value = obsSceneSelect.value;
|
||
});
|
||
|
||
obsSceneNameInput.addEventListener('input', () => {
|
||
// If dropdown is visible, try to select matching option
|
||
if (obsSceneSelect.style.display !== 'none') {
|
||
const matchingOption = Array.from(obsSceneSelect.options)
|
||
.find(option => option.value.toLowerCase() === obsSceneNameInput.value.toLowerCase());
|
||
|
||
if (matchingOption) {
|
||
obsSceneSelect.value = matchingOption.value;
|
||
}
|
||
}
|
||
});
|
||
|
||
|
||
|
||
obsWsUrlInput.addEventListener('change', saveSettings);
|
||
obsWsPasswordInput.addEventListener('change', saveSettings);
|
||
vdoNinjaRoomInput.addEventListener('change', saveSettings);
|
||
vdoNinjaPasswordInput.addEventListener('change', saveSettings);
|
||
vdoNinjaStreamIdsInput.addEventListener('change', saveSettings);
|
||
obsSceneNameInput.addEventListener('change', saveSettings);
|
||
sourceSizingSelect.addEventListener('change', () => {
|
||
saveSettings();
|
||
|
||
// If changed to auto grid, rearrange streams
|
||
if (sourceSizingSelect.value === 'autoGrid') {
|
||
rearrangeAllStreams();
|
||
}
|
||
});
|
||
autoAddSourcesCheckbox.addEventListener('change', saveSettings);
|
||
loadScenesBtn.addEventListener('click', fetchObsScenes);
|
||
obsSceneSelect.parentNode.insertBefore(loadScenesBtn, obsSceneSelect.nextSibling);
|
||
autoRemoveSourcesCheckbox.addEventListener('change', saveSettings);
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
loadSettings();
|
||
logMessage("Dock initialized. Connect to OBS to begin.");
|
||
|
||
const secureFields = document.querySelectorAll('#vdoNinjaRoom, #vdoNinjaStreamIds');
|
||
|
||
secureFields.forEach(field => {
|
||
field.classList.add('blur-field');
|
||
|
||
// Add click handler to remove blur
|
||
field.addEventListener('focus', () => {
|
||
field.classList.remove('blur-field');
|
||
});
|
||
|
||
// Re-add blur when focus is lost
|
||
field.addEventListener('blur', () => {
|
||
field.classList.add('blur-field');
|
||
});
|
||
});
|
||
|
||
// Make sure the autoRemoveSourcesCheckbox exists
|
||
if (!document.getElementById('autoRemoveSources')) {
|
||
const autoSourceOptions = document.getElementById('autoSourceOptions');
|
||
if (autoSourceOptions) {
|
||
const label = document.createElement('label');
|
||
label.setAttribute('for', 'autoRemoveSources');
|
||
label.style.marginTop = '5px';
|
||
label.innerHTML = `
|
||
<input type="checkbox" id="autoRemoveSources" checked>
|
||
Automatically remove sources when streams disconnect
|
||
`;
|
||
autoSourceOptions.appendChild(label);
|
||
|
||
// Add reference to the new checkbox
|
||
const autoRemoveSourcesCheckbox = document.getElementById('autoRemoveSources');
|
||
autoRemoveSourcesCheckbox.addEventListener('change', saveSettings);
|
||
}
|
||
}
|
||
|
||
// Start the connection monitor
|
||
startVdoNinjaConnectionMonitor();
|
||
});
|
||
|
||
</script>
|
||
|
||
</body>
|
||
</html> |