Files
archived-vdo.ninja/obs/index.html
2025-06-07 20:20:59 -03:00

4434 lines
211 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Translation system integrated locally - no external dependencies -->
<title data-i18n="pageTitle">VDO.Ninja OBS Control Dock</title>
<style>
body {
font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 15px;
background-color: #181a1b;
color: #e0e0e0;
font-size: 14px;
line-height: 1.6;
}
.language-switcher {
display: flex;
align-items: center;
gap: 8px;
}
.language-switcher label {
margin-right: 0;
color: #c0c0c0;
font-size: 0.9em;
margin-bottom: 0;
}
.language-switcher select {
padding: 5px 8px;
border-radius: 4px;
background-color: #25272c;
color: #e0e0e0;
border: 1px solid #4a4d54;
font-size: 0.9em;
margin-bottom: 0;
width: auto;
min-width: 120px;
}
h1 {
color: #d0d0d0;
margin: 20px 0 20px 0;
padding-bottom: 15px;
font-size: 1.8em;
font-weight: 600;
border-bottom: 1px solid #3e4147;
}
.container {
margin-bottom: 15px;
padding: 15px;
background-color: #2c2e33;
border-radius: 8px;
border: 1px solid #3e4147;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.collapsible {
cursor: pointer;
user-select: none;
padding: 12px 15px;
position: relative;
font-weight: 600;
font-size: 1.1em;
background: #35383d;
margin: -15px -15px 10px -15px;
padding-left: 15px;
border-bottom: 1px solid #3e4147;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
transition: background-color 0.2s ease;
}
.collapsible.collapsed {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
margin-bottom: 0;
border-bottom: none;
}
.container .collapsible:last-child.collapsed {
margin-bottom: -15px;
}
.collapsible[data-state="expand"]::after {
content: "▼";
}
.collapsible[data-state="collapse"]::after {
content: "▲";
}
.collapsible::after {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
font-size: 14px;
color: #a0a0b0;
transition: transform 0.2s ease;
}
.collapsible[data-state="expand"]::before {
}
.collapsible[data-state="collapse"]::before {
}
.collapsible::before {
content: var(--before-text, "Click");
position: absolute;
right: 40px;
font-size: 10px;
color: #666;
font-weight: normal;
}
.collapsible:hover {
background: #404348;
}
.collapsible-content {
max-height: 1000px;
overflow: hidden;
transition: max-height 0.3s ease-in-out, padding-top 0.3s ease-in-out,
opacity 0.3s ease-in-out;
padding-top: 10px;
opacity: 1;
}
.collapsible-content.collapsed {
max-height: 0;
padding-top: 0;
opacity: 0;
overflow: hidden;
}
.container:nth-of-type(6) .collapsible-content:not(.collapsed) {
max-height: none;
}
label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: #c0c0c0;
}
input[type="text"],
input[type="password"],
input[type="number"],
select,
textarea {
width: calc(
100% - 22px
);
padding: 10px;
margin-bottom: 12px;
border: 1px solid #4a4d54;
border-radius: 6px;
background-color: #25272c;
color: #e0e0e0;
font-size: 1em;
box-sizing: border-box;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
textarea {
min-height: 80px;
resize: vertical;
}
input[type="text"]:focus,
input[type="password"]:focus,
input[type="number"]:focus,
select:focus,
textarea:focus {
border-color: #007aff;
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.25);
outline: none;
}
select {
padding-right: 30px;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23A0A0B0'%3E%3Cpath d='M8 11L3 6h10L8 11z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 12px;
}
select[multiple] {
background-image: none;
min-height: 100px;
padding-right: 10px;
}
button {
padding: 10px 15px;
background-color: #4a5060;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
margin-right: 5px;
margin-bottom: 5px;
font-size: 0.95em;
font-weight: 500;
transition: background-color 0.2s ease, transform 0.1s ease;
}
button:hover {
background-color: #5a6070;
}
button:active {
transform: translateY(1px);
}
button.connected {
background-color: #007aff;
}
button.connected:hover {
background-color: #0056b3;
}
button.disconnected {
background-color: #6c757d;
}
button.disconnected:hover {
background-color: #5a6268;
}
.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: 120px;
background-color: #202225;
color: #b0b0b0;
border: 1px solid #4a4d54;
border-radius: 6px;
overflow-y: scroll;
padding: 10px;
font-family: "Consolas", "Monaco", "Lucida Console", monospace;
font-size: 0.9em;
margin-top: 10px;
white-space: pre-wrap;
}
.status-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-left: 8px;
background-color: #555;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
.status-indicator.connected {
background-color: #007aff;
box-shadow: 0 0 8px rgba(0, 122, 255, 0.7);
}
.status-indicator.error {
background-color: #dc3545;
box-shadow: 0 0 8px rgba(220, 53, 69, 0.7);
}
.stream-list, .layout-config-list {
background-color: #202225;
border: 1px solid #4a4d54;
border-radius: 6px;
padding: 5px;
margin-top: 5px;
transition: max-height 0.3s ease;
}
.stream-list:empty {
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
color: #888;
padding: 10px;
}
.layout-config-list.is-empty {
min-height: 0;
padding: 0;
border: none;
background-color: transparent;
margin: 0;
}
.stream-item, .layout-config-item {
padding: 10px 12px;
border-bottom: 1px solid #383b40;
font-size: 1em;
word-break: break-word;
background-color: #2c2e33;
margin: 0 0 8px 0;
border-radius: 4px;
}
.stream-item:last-child, .layout-config-item:last-child {
border-bottom: none;
margin-bottom: 0;
}
h2 {
color: #c8c8c8;
margin: 5px 0;
font-size: 1.1em;
}
h3 {
color: #b8b8b8;
font-size: 1.0em;
margin-top: 10px;
margin-bottom: 8px;
padding-bottom: 5px;
border-bottom: 1px dashed #4a4d54;
}
small {
color: #9090a0;
font-size: 0.88em;
display: block;
margin-top: 3px;
line-height: 1.4;
}
.add-stream-btn {
background-color: #28a745 !important;
}
.add-stream-btn:hover {
background-color: #218838 !important;
}
button.add-stream-btn[style*="rgb(244, 67, 54)"],
button.add-stream-btn[style*="#F44336"] {
background-color: #dc3545 !important;
}
button.add-stream-btn[style*="rgb(244, 67, 54)"]:hover,
button.add-stream-btn[style*="#F44336"]:hover {
background-color: #c82333 !important;
}
.highlight-btn {
background-color: #17a2b8 !important;
}
.highlight-btn:hover {
background-color: #138496 !important;
}
button.highlight-btn[style*="rgb(244, 67, 54)"],
button.highlight-btn[style*="#F44336"] {
background-color: #ffc107 !important;
color: #212529 !important;
}
button.highlight-btn[style*="rgb(244, 67, 54)"]:hover,
button.highlight-btn[style*="#F44336"]:hover {
background-color: #e0a800 !important;
}
.screen-share-btn {
background-color: #6f42c1 !important;
}
.screen-share-btn:hover {
background-color: #5a2aa8 !important;
}
button.screen-share-btn[style*="rgb(244, 67, 54)"],
button.screen-share-btn[style*="#F44336"] {
background-color: #fd7e14 !important;
}
button.screen-share-btn[style*="rgb(244, 67, 54)"]:hover,
button.screen-share-btn[style*="#F44336"]:hover {
background-color: #e66c00 !important;
}
.status-line {
font-size: 1em;
margin-top: 10px;
display: flex;
align-items: center;
}
#obsConnectionStatus,
#vdoNinjaConnectionStatus {
margin-left: 8px;
color: #b0b0b0;
}
input[type="checkbox"] {
accent-color: #007aff;
margin-right: 8px;
width: 15px;
height: 15px;
vertical-align: middle;
flex-shrink: 0;
}
.checkbox-label {
display: flex;
align-items: center;
margin-bottom: 5px;
color: #c0c0c0;
font-size: 0.95em;
}
.checkbox-label input {
margin-bottom: 0;
margin-right: 8px;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: #202225;
border-radius: 5px;
}
::-webkit-scrollbar-thumb {
background: #4a4d54;
border-radius: 5px;
border: 2px solid #202225;
}
::-webkit-scrollbar-thumb:hover {
background: #007aff;
}
* {
scrollbar-width: auto;
scrollbar-color: #4a4d54 #202225;
}
.flex-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
#obsSceneNameInput {
display: none !important;
}
.prefix-input-label {
margin-bottom: 4px;
display: block;
font-size: 0.9em;
color: #a0a0b0;
}
.prefix-group {
margin-bottom: 10px;
}
a[href^="https://docs.vdo.ninja/advanced-settings/video-parameters/codec"] {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
color: #a0a0b0;
font-size: 12px;
border: 1px solid #a0a0b0;
border-radius: 50%;
width: 18px;
height: 18px;
font-weight: bold;
vertical-align: middle;
margin-left: 4px;
margin-bottom: 0;
transition: background-color 0.2s ease, color 0.2s ease,
border-color 0.2s ease;
}
a[href^="https://docs.vdo.ninja/advanced-settings/video-parameters/codec"]:hover {
background-color: #007aff;
color: white;
border-color: #007aff;
}
.stream-mapping, .layout-config {
background-color: #25272c;
padding: 12px;
border: 1px solid #383b40;
border-radius: 6px;
margin-bottom: 10px;
}
.stream-mapping .flex-row, .layout-config .flex-row {
margin-bottom: 8px;
}
.stream-mapping .flex-row:last-child, .layout-config .flex-row:last-child {
margin-bottom: 0;
}
.stream-mapping input[type="text"],
.stream-mapping select,
.layout-config select {
margin-bottom: 0;
font-size: 0.9em;
padding: 8px;
}
.stream-mapping .remove-mapping-btn, .layout-config .remove-layout-btn {
padding: 6px 8px;
background-color: #c82333 !important;
font-size: 0.9em;
margin-left: auto;
}
.stream-mapping .remove-mapping-btn:hover, .layout-config .remove-layout-btn:hover {
background-color: #a01c28 !important;
}
.stream-mapping small, .layout-config small {
font-size: 0.8em;
margin-top: 5px;
}
.mapping-stream-id, .layout-config-scene-select {
flex-basis: 100px;
flex-grow: 1;
min-width: 80px;
}
.mapping-label, .layout-config-type-select {
flex-basis: 140px;
flex-grow: 2;
min-width: 100px;
}
.mapping-match-type {
flex-basis: 100px;
flex-grow: 1;
min-width: 90px;
}
.mapping-scene-name {
flex-basis: 150px;
flex-grow: 2;
min-width: 120px;
}
.remove-mapping-btn, .remove-layout-btn {
flex-shrink: 0;
}
#addStreamMappingBtn, #addLayoutConfigBtn {
background-color: #0069d9;
border-color: #0062cc;
}
#addStreamMappingBtn:hover, #addLayoutConfigBtn:hover {
background-color: #005cbf;
border-color: #0056b3;
}
.stream-mapping .checkbox-label, .layout-config .checkbox-label {
font-size: 0.9em;
margin-bottom: 0;
}
.settings-group {
margin-top: 15px;
border-top: 1px solid #3e4147;
padding-top: 15px;
}
.settings-group:first-child {
margin-top: 0;
border-top: none;
padding-top: 0;
}
.layout-config-item .flex-row {
align-items: center;
}
.layout-config-item .flex-row label {
margin-bottom: 0;
margin-right: 5px;
}
.layout-config-item .flex-row select {
flex-grow: 1;
width: auto;
}
.layout-config-item .remove-layout-btn {
margin-left: 10px;
}
.layout-specific-controls {
padding: 10px;
margin-top: 10px;
background-color: #25272c;
border-radius: 4px;
border: 1px solid #383b40;
}
.layout-specific-controls label {
font-size: 0.9em;
color: #b0b0b0;
margin-bottom: 4px;
}
.layout-specific-controls input[type="range"] {
width: calc(100% - 100px);
margin-bottom: 0px;
}
.layout-specific-controls input[type="number"] {
width: 60px;
padding: 6px;
font-size: 0.9em;
margin-bottom: 0px;
}
.layout-specific-controls .slider-container {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.layout-specific-controls .slider-container input[type="range"] {
flex-grow: 1;
margin-bottom: 0;
}
.layout-specific-controls .slider-container .slider-label {
min-width: 80px;
flex-shrink: 0;
}
.layout-specific-controls .slider-container span {
min-width: 35px;
text-align: right;
font-size: 0.9em;
color: #c0c0c0;
display: none;
}
.layout-specific-controls .checkbox-label {
font-size: 0.9em;
margin-top: 8px;
}
#sourceCreationScenesContainer {
display: flex;
flex-direction: column;
width: 300px;
flex-grow: 1;
max-height: 150px;
overflow-y: auto;
border: 1px solid #4a4d54;
border-radius: 6px;
padding: 10px;
background-color: #25272c;
}
.checkbox-item-row {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.checkbox-item-row:last-child {
margin-bottom: 0;
}
.checkbox-item-row input[type="checkbox"] {
margin-right: 8px;
}
.checkbox-item-row label {
margin-bottom: 0;
font-weight: normal;
color: #e0e0e0;
cursor: pointer;
flex-grow: 1;
}
.stream-item {
padding: 4px 6px !important;
}
.stream-item div[style*="font-weight: 600"] {
font-size: 0.7em !important;
margin-bottom: 1px !important;
}
.stream-item small {
line-height: 1.1 !important;
}
.stream-item small[style*="display: block"] {
display: inline !important;
}
.stream-item div[style*="margin-top: 2px"] {
display: inline !important;
margin-top: 0 !important;
margin-left: 8px;
}
.stream-item div[style*="margin-top: 2px"]::before {
content: "|";
margin-right: 8px;
color: #6c757d;
}
.stream-item .flex-row {
margin-top: 4px !important;
}
.layout-config-item .remove-layout-btn {
padding: 6px 8px !important;
font-size: 0.9em !important;
background-color: #c82333 !important;
}
.layout-config-item .remove-layout-btn:hover {
background-color: #a01c28 !important;
}
</style>
<style id="customUserCss"></style>
</head>
<body>
<h1 data-i18n="mainHeading">VDO.Ninja OBS Control</h1>
<div class="container">
<div class="language-switcher">
<label for="languageSelector" data-i18n="languageSwitcher.label">Language:</label>
<select id="languageSelector">
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="it">Italiano</option>
<option value="pt">Português</option>
<option value="ru">Русский</option>
<option value="ja">日本語</option>
<option value="ko">한국어</option>
<option value="zh-CN">简体中文</option>
<option value="nl">Nederlands</option>
<option value="pl">Polski</option>
</select>
</div>
</div>
<div class="container">
<h2 class="collapsible" data-state="expand" data-i18n="obsConnection.title">OBS WebSocket Connection</h2>
<div class="collapsible-content">
<label for="obsWsUrl" data-i18n="obsConnection.websocketUrlLabel">WebSocket URL:</label>
<input type="text" id="obsWsUrl" value="ws://localhost:4455" />
<label for="obsWsPassword" data-i18n="obsConnection.passwordLabel">Password:</label>
<input type="password" id="obsWsPassword" value="" />
<div class="prefix-group">
<label
for="cameraPrefix"
id="cameraSubPrefixLabelText"
class="prefix-input-label"
data-i18n="obsConnection.cameraPrefixLabel"
>General Camera prefix:</label
>
<input type="text" id="cameraPrefix" value="VDO" />
</div>
<div class="prefix-group">
<label
for="reactionSubPrefix"
id="reactionSubPrefixLabelText"
class="prefix-input-label"
data-i18n="obsConnection.reactionPrefixLabel"
>Reaction prefix: VDO.</label
>
<input type="text" id="reactionSubPrefix" value="Screen" />
</div>
<div class="prefix-group">
<label
for="highlightSubPrefix"
id="highlightSubPrefixLabelText"
class="prefix-input-label"
data-i18n="obsConnection.highlightPrefixLabel"
>Highlight prefix: VDO.</label
>
<input type="text" id="highlightSubPrefix" value="Highlight" />
</div>
<div class="status-line">
<button id="obsConnectBtn" data-i18n="obsConnection.connectButton">Connect</button>
<span id="obsConnectionStatus" data-i18n="obsConnection.statusDisconnected">Status: Disconnected</span>
<span id="obsStatusIndicator" class="status-indicator"></span>
</div>
</div>
</div>
<div class="container">
<h2 class="collapsible" data-state="expand" data-i18n="vdoNinjaSettings.title">VDO.Ninja Settings</h2>
<div class="collapsible-content">
<label for="vdoNinjaBaseUrl" data-i18n="vdoNinjaSettings.baseUrlLabel">VDO.Ninja Base URL:</label>
<input
type="text"
id="vdoNinjaBaseUrl"
data-i18n="[placeholder]vdoNinjaSettings.baseUrlPlaceholder"
placeholder="https://vdo.ninja"
value="https://vdo.ninja"
/>
<label for="vdoNinjaRoom" data-i18n="vdoNinjaSettings.roomNameLabel">Room Name:</label>
<input
type="text"
id="vdoNinjaRoom"
data-i18n="[placeholder]vdoNinjaSettings.roomNamePlaceholder"
placeholder="e.g., MyNinjaRoom"
class="blur-field"
/>
<label for="vdoNinjaPassword" data-i18n="vdoNinjaSettings.passwordLabel">Password:</label>
<input
type="password"
id="vdoNinjaPassword"
data-i18n="[placeholder]vdoNinjaSettings.passwordPlaceholder"
placeholder="Room or &password"
/>
<label for="vdoNinjaStreamIds" data-i18n="vdoNinjaSettings.streamIdsLabel">Stream IDs:</label>
<input
type="text"
id="vdoNinjaStreamIds"
data-i18n="[placeholder]vdoNinjaSettings.streamIdsPlaceholder"
placeholder="streamId1,streamId2"
class="blur-field"
/>
<small data-i18n="vdoNinjaSettings.roomOrStreamIdsNeeded">Room Name or Stream ID(s) needed</small>
</div>
</div>
<div class="container">
<h2 class="collapsible" data-state="collapse" data-i18n="streamIdMappings.title">Stream ID Mappings</h2>
<div class="collapsible-content collapsed">
<div id="streamMappingContainer">
<div id="streamMappings"></div>
<button id="addStreamMappingBtn" style="margin-top: 10px" data-i18n="streamIdMappings.addNewMappingButton">
Add New Mapping
</button>
</div>
</div>
</div>
<div class="container">
<h2 class="collapsible" data-state="expand" data-i18n="obsTargetSettings.title">OBS Target Settings</h2>
<div class="collapsible-content">
<div class="settings-group">
<label data-i18n="obsTargetSettings.sourceCreationScenesLabel">Cenas para Criação de Fontes:</label>
<small data-i18n="obsTargetSettings.sourceCreationScenesDesc">Selecione uma ou mais cenas. A primeira marcada será a principal, as outras para cópia.</small>
<div class="flex-row" style="gap: 10px; margin-top: 5px; align-items: flex-start;">
<div id="sourceCreationScenesContainer">
<span data-i18n="obsTargetSettings.loadingScenes">Carregando cenas...</span>
</div>
<button id="loadSourceCreationScenesBtn" data-i18n="obsTargetSettings.refetchScenesButton">Re-Fetch Scenes</button>
</div>
</div>
<input type="text" id="obsSceneNameInput" style="display: none" />
<div class="settings-group">
<label style="font-weight: 600; font-size: 1.05em" data-i18n="obsTargetSettings.screenShareSettings.title">Screen Sharing Settings:</label>
<div class="flex-row" style="gap: 15px; align-items: flex-start">
<div class="flex-row" style="gap: 8px">
<div>
<label for="screenShareWidth" style="font-size: 0.95em" data-i18n="obsTargetSettings.screenShareSettings.widthLabel">Width:</label>
<input
type="number"
id="screenShareWidth"
value="1920"
style="width: 100px"
/>
</div>
<div>
<label for="screenShareHeight" style="font-size: 0.95em" data-i18n="obsTargetSettings.screenShareSettings.heightLabel">Height:</label>
<input
type="number"
id="screenShareHeight"
value="1080"
style="width: 100px"
/>
</div>
</div>
</div>
<small data-i18n="obsTargetSettings.screenShareSettings.resolutionNote">This resolution will be used for screen sharing sources in scenes with a 'Reaction' layout.</small>
</div>
<div
id="autoSourceOptions"
class="settings-group flex-row"
style="gap: 15px"
>
<label class="checkbox-label">
<input type="checkbox" id="autoAddSources" checked />
<span data-i18n="obsTargetSettings.autoAddSourcesLabel">Auto-add new streams as sources</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="autoRemoveSources" checked />
<span data-i18n="obsTargetSettings.autoRemoveSourcesLabel">Auto-remove sources on disconnect</span>
</label>
</div>
<div class="settings-group">
<div class="flex-row" style="align-items: flex-start; gap: 15px">
<div>
<label for="sourceSizing" data-i18n="obsTargetSettings.newSourceSizing.label">Default Source Sizing:</label>
<select id="sourceSizing" style="width: 250px">
<option value="defaultSize" data-i18n="obsTargetSettings.newSourceSizing.defaultSizeOption">Default (1920x1080 at 0,0)</option>
<option value="bestFit" data-i18n="obsTargetSettings.newSourceSizing.bestFitOption">Best Fit (Preserve Aspect)</option>
<option value="stretchToFill" data-i18n="obsTargetSettings.newSourceSizing.stretchToFillOption">Stretch to Fill Screen</option>
</select>
</div>
<div>
<label for="sourceCodec" data-i18n="obsTargetSettings.codec.label">Codec:</label>
<div class="flex-row" style="gap: 2px">
<select id="sourceCodec" style="width: 150px">
<option value="" data-i18n="obsTargetSettings.codec.noneOption">none</option>
<option value="h264">h264</option>
<option value="vp8">vp8</option>
<option value="vp9">vp9</option>
<option value="av1">av1</option>
<option value="h265">h265</option>
<option value="webp">webp</option>
<option value="hardware">hardware</option>
</select>
<a
href="https://docs.vdo.ninja/advanced-settings/video-parameters/codec"
target="_blank"
data-i18n="[title]obsTargetSettings.codec.learnMoreTitle"
title="Learn more about codec options"
>?</a
>
</div>
</div>
</div>
<small data-i18n="obsTargetSettings.newSourceSizing.overrideNote">Specific layouts defined in the "Layouts" section will override these for those scenes.</small>
</div>
</div>
</div>
<div class="container"> <h2 class="collapsible" data-state="collapse" data-i18n="sceneLayouts.title">Scene Layouts</h2>
<div class="collapsible-content collapsed">
<div id="layoutConfigContainer">
<div id="layoutConfigsList" class="layout-config-list">
</div>
<button id="addLayoutConfigBtn" style="margin-top: 10px;" data-i18n="sceneLayouts.addNewLayoutButton">
Add New Layout
</button>
<small data-i18n="sceneLayouts.description">Define automatic layouts for specific OBS scenes. These will override the default source sizing for the configured scenes.</small>
</div>
</div>
</div>
<div class="container">
<h2 class="collapsible" data-state="expand" data-i18n="activeStreams.title">Active Streams</h2>
<div class="collapsible-content">
<div id="streamList" class="stream-list">
<div
class="stream-item"
style="
text-align: center;
background-color: transparent;
border: none;
color: #888;
"
data-i18n="activeStreams.noActiveStreams"
>
No active streams
</div>
</div>
</div>
</div>
<div class="container">
<h2 class="collapsible" data-state="collapse" data-i18n="customCss.title">Custom CSS</h2>
<div class="collapsible-content collapsed">
<label for="customCssInput" data-i18n="customCss.label">Enter your custom CSS here:</label>
<textarea id="customCssInput" rows="10"></textarea>
<small data-i18n="customCss.description">This CSS will be applied to the sources.</small>
</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" data-state="expand" data-i18n="log.title">Log</h2>
<div class="collapsible-content">
<div id="logArea" class="log-area"></div>
</div>
</div>
<script>
// Translation files are loaded from local ./locales/ directory
let logEntries = [];
const languageSelector = document.getElementById('languageSelector');
// Default values for layouts (can be overridden by specific layout settings)
const DEFAULT_GRID_MARGIN = 75;
const DEFAULT_GRID_SPACING = 30;
const DEFAULT_GRID_X_OFFSET = 0;
const DEFAULT_GRID_Y_OFFSET = 0;
const DEFAULT_GRID_SPLIT_SCREEN = false;
const DEFAULT_REACTION_HIGHLIGHT_SPACING = 15;
const DEFAULT_REACTION_HIGHLIGHT_X_OFFSET = 0;
const DEFAULT_REACTION_HIGHLIGHT_Y_OFFSET = 0;
const DEFAULT_REACTION_HIGHLIGHT_DISTRIBUTE_CAMERAS = false;
const CELL_ASPECT_RATIO = 16 / 9;
const REACTION_CAMERA_CROP_RATIO = 3/4;
const HIGHLIGHT_CAMERA_CROP_RATIO = 3/4;
const REACTION_HIGHLIGHT_ASPECT_RATIO = 16 / 9;
// Global variable to store initially selected scenes for checkbox population
let initialSelectedSourceCreationScenes = [];
// Translation system variables
window.translation = {};
window.miscTranslations = {};
// Get translation with fallback
function getTranslation(key, params = null) {
if (!window.translation) {
// If params is a string, use it as fallback
if (typeof params === 'string') return params;
return key;
}
// Handle nested keys like "obsConnection.title"
const keys = key.split('.');
let value = window.translation;
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
// If params is a string, use it as fallback
if (typeof params === 'string') return params;
return key;
}
}
// If no value found
if (!value) {
if (typeof params === 'string') return params;
return key;
}
// If params is an object, do parameter substitution
if (params && typeof params === 'object') {
let result = value;
Object.keys(params).forEach(param => {
result = result.replace(new RegExp(`{{${param}}}`, 'g'), params[param]);
});
return result;
}
return value || key;
}
// Function to initialize the application
async function initializeApp() {
let savedLanguage = localStorage.getItem('vdoNinjaObsControlLanguage') || 'en';
languageSelector.value = savedLanguage;
// Load translations from locales directory
try {
const response = await fetch(`./locales/${savedLanguage}.json`);
if (response.ok) {
const localeData = await response.json();
window.translation = localeData;
window.miscTranslations = localeData;
} else {
console.error('Failed to load translations, using fallbacks');
}
} catch (err) {
console.error('Error loading translations:', err);
}
loadSettings();
updateContent();
logMessage("VDO.Ninja OBS Control Dock Initialized. Welcome!", "logMessages.appInitialized");
updateVdoNinjaButtonState(false);
toggleVdoNinjaInputs(false);
setupSecureFieldsBlur();
startVdoNinjaConnectionMonitor();
setupLayoutConfigUI();
}
// Function to update translated content on the page
function updateContent() {
// Update data-i18n elements (compatible with original structure)
document.querySelectorAll('[data-i18n]').forEach(element => {
const keyAttr = element.getAttribute('data-i18n');
const keys = keyAttr.split(';').map(k => k.trim()).filter(k => k);
keys.forEach(keyInstruction => {
if (keyInstruction.startsWith('[placeholder]')) {
const translationKey = keyInstruction.substring('[placeholder]'.length);
element.placeholder = getTranslation(translationKey);
} else if (keyInstruction.startsWith('[title]')) {
const translationKey = keyInstruction.substring('[title]'.length);
element.title = getTranslation(translationKey);
} else {
element.innerHTML = getTranslation(keyInstruction);
}
});
});
// Update data-translate elements (new system)
document.querySelectorAll('[data-translate]').forEach(element => {
const keyAttr = element.getAttribute('data-translate');
const keys = keyAttr.split(';').map(k => k.trim()).filter(k => k);
keys.forEach(keyInstruction => {
if (keyInstruction.startsWith('[placeholder]')) {
element.placeholder = getTranslation(keyInstruction.substring('[placeholder]'.length));
} else if (keyInstruction.startsWith('[title]')) {
element.title = getTranslation(keyInstruction.substring('[title]'.length));
} else {
element.innerHTML = getTranslation(keyInstruction);
}
});
});
document.querySelectorAll('.stream-mapping .mapping-stream-id').forEach(input => {
input.placeholder = getTranslation('streamIdMappings.streamIdPlaceholder');
input.title = getTranslation('streamIdMappings.streamIdTooltip');
});
document.querySelectorAll('.stream-mapping .mapping-label').forEach(input => {
input.placeholder = getTranslation('streamIdMappings.labelPlaceholder');
input.title = getTranslation('streamIdMappings.labelTooltip');
});
document.querySelectorAll('.layout-config-item .layout-scene-select option[value=""]').forEach(opt => {
opt.textContent = getTranslation('sceneLayouts.selectSceneOption');
});
document.querySelectorAll('.layout-config-item .layout-type-select option[value=""]').forEach(opt => {
opt.textContent = getTranslation('sceneLayouts.selectLayoutOption');
});
// Update dynamically created layout controls
document.querySelectorAll('.layout-specific-controls [data-i18n-label]').forEach(label => {
if (label.dataset.i18nLabel && window.translation) {
label.textContent = getTranslation(label.dataset.i18nLabel);
}
});
document.querySelectorAll('h3[data-i18n-fallback-header]').forEach(header => {
if (header.dataset.i18nFallbackHeader && window.translation) {
header.textContent = getTranslation(header.dataset.i18nFallbackHeader);
}
});
updateObsConnectButtonText();
updateVdoNinjaButtonState(vdoNinjaConnected);
updateStreamList();
updatePrefixLabels();
setupCollapsibleTooltips();
updateSceneDropdowns();
renderLog();
}
// Setup tooltips for collapsible sections
function setupCollapsibleTooltips() {
document.querySelectorAll(".collapsible").forEach(header => {
const isCollapsed = header.classList.contains("collapsed");
const tooltipKey = isCollapsed ? "collapsible.clickToExpand" : "collapsible.clickToCollapse";
header.style.setProperty('--before-text', `"${getTranslation(tooltipKey)}"`);
header.setAttribute('aria-label', getTranslation(tooltipKey));
});
}
// Event listener for language selector
languageSelector.addEventListener('change', async (event) => {
const newLang = event.target.value;
try {
const response = await fetch(`./locales/${newLang}.json`);
if (response.ok) {
const localeData = await response.json();
window.translation = localeData;
window.miscTranslations = localeData;
localStorage.setItem('vdoNinjaObsControlLanguage', newLang);
updateContent();
} else {
console.error('Failed to load language:', newLang);
}
} catch (err) {
console.error('Error changing language:', err);
}
});
// UI 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 cameraPrefixInput = document.getElementById("cameraPrefix");
const reactionSubPrefixInput =document.getElementById("reactionSubPrefix");
const highlightSubPrefixInput = document.getElementById("highlightSubPrefix");
const cameraSubPrefixLabelText = document.getElementById("cameraSubPrefixLabelText");
const reactionSubPrefixLabelText = document.getElementById("reactionSubPrefixLabelText");
const highlightSubPrefixLabelText = document.getElementById("highlightSubPrefixLabelText");
const vdoNinjaBaseUrlInput = document.getElementById("vdoNinjaBaseUrl");
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 sourceCreationScenesContainer = document.getElementById("sourceCreationScenesContainer");
const loadSourceCreationScenesBtn = document.getElementById("loadSourceCreationScenesBtn");
const sourceSizingSelect = document.getElementById("sourceSizing");
const sourceCodecSelect = document.getElementById("sourceCodec");
const autoAddSourcesCheckbox = document.getElementById("autoAddSources");
const autoRemoveSourcesCheckbox = document.getElementById("autoRemoveSources");
const streamListContainer = document.getElementById("streamList");
const logArea = document.getElementById("logArea");
const customCssInput = document.getElementById("customCssInput");
const customUserCssStyleTag = document.getElementById("customUserCss");
// State Variables
let obs = null;
let obsConnected = false;
let vdoNinjaConnected = false;
let activeStreams = {};
let obsScenes = [];
let requestCallbacks = {};
let vdoNinjaLastActivityTime = 0;
let vdoNinjaConnectionCheckTimer = null;
let screenShareId = null;
let highlightedStreamId = null;
const layoutConfigsListDiv = document.getElementById('layoutConfigsList');
const addLayoutConfigBtn = document.getElementById('addLayoutConfigBtn');
let sceneLayoutConfigs = [];
// Setup collapsible sections
document.querySelectorAll(".collapsible").forEach((header) => {
const content = header.nextElementSibling;
const startsExpanded = header.dataset.state === "expand";
if (startsExpanded) {
header.classList.remove("collapsed");
content.classList.remove("collapsed");
header.setAttribute("data-state", "collapse");
} else {
header.classList.add("collapsed");
content.classList.add("collapsed");
header.setAttribute("data-state", "expand");
}
header.addEventListener("click", function () {
const isCollapsed = header.classList.toggle("collapsed");
content.classList.toggle("collapsed");
const newState = isCollapsed ? "expand" : "collapse";
header.setAttribute("data-state", newState);
setupCollapsibleTooltips();
});
});
// Create and inject VDO.Ninja connect button and status
const vdoNinjaConnectBtn = document.createElement("button");
vdoNinjaConnectBtn.id = "vdoNinjaConnectBtn";
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.style.marginLeft = "5px";
const vdoNinjaSettingsContainer = document.querySelector(
"div.container:nth-of-type(3) .collapsible-content"
);
const buttonsDiv = document.createElement("div");
buttonsDiv.style.marginTop = "10px";
buttonsDiv.className = "status-line";
buttonsDiv.appendChild(vdoNinjaConnectBtn);
buttonsDiv.appendChild(vdoNinjaConnectionStatus);
buttonsDiv.appendChild(vdoNinjaStatusIndicator);
if (vdoNinjaSettingsContainer) {
vdoNinjaSettingsContainer.appendChild(buttonsDiv);
} else {
console.error("VDO.Ninja settings container not found for injecting connect button.");
}
vdoNinjaConnectBtn.addEventListener("click", () => {
if (vdoNinjaConnected) {
disconnectFromVdoNinja();
} else {
connectToVdoNinja();
}
});
// Sleep function
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Log messages function
function logMessage(fallbackMessage, i18nKey, i18nParams = {}) {
const timestamp = new Date().toLocaleTimeString();
logEntries.push({
timestamp,
i18nKey,
i18nParams,
fallbackMessage
});
if (logEntries.length > 200) {
logEntries.shift();
}
renderLog();
}
// Render log function
function renderLog() {
if (!logArea || !window.translation) {
return;
}
let logContent = "";
logEntries.forEach(entry => {
let translatedMessage = entry.fallbackMessage;
if (entry.i18nKey && getTranslation(entry.i18nKey)) {
translatedMessage = getTranslation(entry.i18nKey, entry.i18nParams);
} else if (entry.i18nKey) {
// console.warn(`i18n key not found: ${entry.i18nKey}`);
}
const safeTranslatedMessage = translatedMessage.replace(/</g, "&lt;").replace(/>/g, "&gt;");
logContent += `[${entry.timestamp}] ${safeTranslatedMessage}\n`;
});
logArea.innerHTML = logContent;
logArea.scrollTop = logArea.scrollHeight;
}
// Update layout configs list empty state
function updateLayoutConfigsListEmptyState() {
if (layoutConfigsListDiv && layoutConfigsListDiv.children.length === 0) {
layoutConfigsListDiv.classList.add('is-empty');
} else if (layoutConfigsListDiv) {
layoutConfigsListDiv.classList.remove('is-empty');
}
}
// Generate unique request ID
function generateRequestId(type) {
return `${type}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
// Generate UUID
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// Get VDO.Ninja base URL
function getVdoNinjaBaseUrl() {
const customUrl = vdoNinjaBaseUrlInput.value.trim();
return customUrl || "https://vdo.ninja";
}
// Get full camera prefix
function getFullCameraPrefix() {
return cameraPrefixInput.value.trim();
}
// Get full reaction prefix
function getFullReactionPrefix() {
const camPrefix = getFullCameraPrefix();
const reactSub = reactionSubPrefixInput.value.trim();
return reactSub ? `${camPrefix}.${reactSub}` : camPrefix;
}
// Get full highlight prefix
function getFullHighlightPrefix() {
const camPrefix = getFullCameraPrefix();
const hlSub = highlightSubPrefixInput.value.trim();
return hlSub ? `${camPrefix}.${hlSub}` : camPrefix;
}
// Update prefix labels
function updatePrefixLabels() {
if (!window.translation) return;
const cameraPrefixVal = getFullCameraPrefix();
const reactionSubPrefixVal = reactionSubPrefixInput.value.trim();
const highlightSubPrefixVal = highlightSubPrefixInput.value.trim();
cameraSubPrefixLabelText.textContent = getTranslation("obsConnection.cameraPrefixLabel");
let reactionText = getTranslation("obsConnection.reactionPrefixDynamicLabel", "Reaction prefix: {{prefix}}{{separator}}{{subPrefix}}");
reactionText = reactionText.replace('{{prefix}}', cameraPrefixVal || "VDO")
.replace('{{separator}}', (cameraPrefixVal && reactionSubPrefixVal) ? "." : "")
.replace('{{subPrefix}}', reactionSubPrefixVal || "");
reactionSubPrefixLabelText.textContent = reactionText;
let highlightText = getTranslation("obsConnection.highlightPrefixDynamicLabel", "Highlight prefix: {{prefix}}{{separator}}{{subPrefix}}");
highlightText = highlightText.replace('{{prefix}}', cameraPrefixVal || "VDO")
.replace('{{separator}}', (cameraPrefixVal && highlightSubPrefixVal) ? "." : "")
.replace('{{subPrefix}}', highlightSubPrefixVal || "");
highlightSubPrefixLabelText.textContent = highlightText;
}
// Event listeners for prefix inputs
cameraPrefixInput.addEventListener("input", () => {
updatePrefixLabels();
saveSettings();
});
reactionSubPrefixInput.addEventListener("input", () => {
updatePrefixLabels();
saveSettings();
});
highlightSubPrefixInput.addEventListener("input", () => {
updatePrefixLabels();
saveSettings();
});
// Toggle disabled state of VDO.Ninja inputs
function toggleVdoNinjaInputs(disabled) {
vdoNinjaBaseUrlInput.disabled = disabled;
vdoNinjaRoomInput.disabled = disabled;
vdoNinjaPasswordInput.disabled = disabled;
vdoNinjaStreamIdsInput.disabled = disabled;
}
// Update OBS connect button text
function updateObsConnectButtonText() {
if (obsConnectBtn && window.translation) {
obsConnectBtn.textContent = obsConnected ? getTranslation("obsConnection.disconnectButton") : getTranslation("obsConnection.connectButton");
}
}
// Get input settings safely
async function getInputSettingsSafe(sourceName, defaultWidth = 1920, defaultHeight = 1080) {
try {
const settings = await sendRequest("GetInputSettings", { inputName: sourceName });
if (settings && settings.inputSettings) {
return {
width: settings.inputSettings.width || defaultWidth,
height: settings.inputSettings.height || defaultHeight,
};
}
} catch (e) {
// logMessage(`Could not get settings for ${sourceName}, using defaults. Error: ${e.message}`, "logMessages.obs.getInputSettingsSafeError", {sourceName, message: e.message});
}
return { width: defaultWidth, height: defaultHeight };
}
// Hide source item in a scene
async function hideSourceItem(sceneName, sceneItemId, sourceNameForLog = "Unknown") {
if (!sceneName || !sceneItemId) return;
try {
await sendRequest("SetSceneItemEnabled", {
sceneName: sceneName,
sceneItemId: sceneItemId,
sceneItemEnabled: false
});
// logMessage(`Hid source item ${sourceNameForLog} in ${sceneName}.`, "logMessages.hidSourceItem", {sourceName: sourceNameForLog, sceneName});
} catch(e) {
logMessage(`Error hiding source item ${sourceNameForLog} in ${sceneName}: ${e.message}`, "logMessages.errorHidingSource", {sourceName: sourceNameForLog, sceneName, message: e.message});
}
}
// Update VDO.Ninja connect button state
function updateVdoNinjaButtonState(isVdoConnected) {
vdoNinjaConnected = isVdoConnected;
if (!window.translation) return;
if (vdoNinjaConnected) {
vdoNinjaConnectBtn.textContent = getTranslation("vdoNinjaSettings.disconnectButton");
vdoNinjaConnectBtn.classList.remove("disconnected");
vdoNinjaConnectBtn.classList.add("connected");
vdoNinjaConnectionStatus.textContent = getTranslation("vdoNinjaSettings.statusConnected");
vdoNinjaStatusIndicator.classList.add("connected");
vdoNinjaStatusIndicator.classList.remove("error");
} else {
vdoNinjaConnectBtn.textContent = getTranslation("vdoNinjaSettings.connectButton");
vdoNinjaConnectBtn.classList.remove("connected");
vdoNinjaConnectBtn.classList.add("disconnected");
vdoNinjaConnectionStatus.textContent = getTranslation("vdoNinjaSettings.statusDisconnected");
vdoNinjaStatusIndicator.classList.remove("connected", "error");
}
toggleVdoNinjaInputs(vdoNinjaConnected);
}
// Apply custom CSS
function applyCustomCss(cssString) {
if (customUserCssStyleTag) {
customUserCssStyleTag.textContent = cssString;
}
}
customCssInput.addEventListener('input', () => {
saveSettings();
logMessage("Custom CSS input changed. It will be applied directly to OBS source settings on next creation/update.", "logMessages.customCssChangedWillApplyToSource");
});
// Save settings to localStorage
function saveSettings() {
const selectedSourceCreationOptions = [];
const checkboxes = document.querySelectorAll('#sourceCreationScenesContainer input[type="checkbox"]:checked');
checkboxes.forEach(checkbox => selectedSourceCreationOptions.push(checkbox.value));
const settings = {
obsWsUrl: obsWsUrlInput.value,
obsWsPassword: obsWsPasswordInput.value,
cameraPrefix: cameraPrefixInput.value,
reactionSubPrefix: reactionSubPrefixInput.value,
highlightSubPrefix: highlightSubPrefixInput.value,
vdoNinjaBaseUrl: vdoNinjaBaseUrlInput.value,
vdoNinjaRoom: vdoNinjaRoomInput.value,
vdoNinjaPassword: vdoNinjaPasswordInput.value,
vdoNinjaStreamIds: vdoNinjaStreamIdsInput.value,
selectedSourceCreationScenes: selectedSourceCreationOptions,
sourceSizing: sourceSizingSelect.value,
sourceCodec: sourceCodecSelect.value,
autoAddSources: autoAddSourcesCheckbox.checked,
autoRemoveSources: autoRemoveSourcesCheckbox.checked,
screenShareWidth: document.getElementById("screenShareWidth").value,
screenShareHeight: document.getElementById("screenShareHeight").value,
language: languageSelector.value || 'en',
customCSS: customCssInput.value
};
localStorage.setItem("obsNinjaSettings", JSON.stringify(settings));
const mappings = getStreamMappings();
localStorage.setItem("obsNinjaStreamMappings", JSON.stringify(mappings));
saveLayoutConfigs();
}
// Disconnect from VDO.Ninja
function disconnectFromVdoNinja() {
vdoNinjaIframe.src = "about:blank";
if (vdoNinjaConnectionCheckTimer) {
clearTimeout(vdoNinjaConnectionCheckTimer);
vdoNinjaConnectionCheckTimer = null;
}
activeStreams = {};
updateStreamList();
updateVdoNinjaButtonState(false);
logMessage("Disconnected from VDO.Ninja.", "logMessages.vdoNinja.disconnected");
}
// Connect to VDO.Ninja
function connectToVdoNinja() {
if (vdoNinjaConnected) {
logMessage("Already connected to VDO.Ninja.", "logMessages.vdoNinja.alreadyConnected");
return;
}
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.",
"logMessages.vdoNinja.errorRoomOrStreamIdNeeded"
);
return;
}
initializeVdoNinjaIframe();
if (window.translation) {
vdoNinjaConnectionStatus.textContent = getTranslation("vdoNinjaSettings.statusConnecting");
vdoNinjaConnectBtn.textContent = getTranslation("vdoNinjaSettings.cancelButton");
}
if (vdoNinjaConnectionCheckTimer) {
clearTimeout(vdoNinjaConnectionCheckTimer);
}
vdoNinjaConnectionCheckTimer = setTimeout(() => {
if (
!vdoNinjaConnected &&
Date.now() - vdoNinjaLastActivityTime > 10000
) {
logMessage(
"VDO.Ninja connection timed out. No activity received from iframe.",
"logMessages.vdoNinja.connectionTimeout"
);
if (window.translation) {
vdoNinjaConnectionStatus.textContent = getTranslation("vdoNinjaSettings.statusConnectionFailed");
vdoNinjaConnectBtn.textContent = getTranslation("vdoNinjaSettings.connectButton");
}
vdoNinjaStatusIndicator.classList.add("error");
toggleVdoNinjaInputs(false);
}
}, 10000);
toggleVdoNinjaInputs(true);
saveSettings();
}
// Add new stream mapping to UI
function addNewStreamMapping(
streamId = "",
label = "",
sceneName = "",
matchType = "streamId",
shouldClone = true,
shouldSwitch = false
) {
const streamMappingsDiv = document.getElementById("streamMappings");
const mappingDiv = document.createElement("div");
mappingDiv.className = "stream-mapping";
mappingDiv.dataset.mappingId = generateUUID();
const initialStreamId =
streamId instanceof PointerEvent ||
(typeof streamId === "object" && streamId !== null && streamId.target)
? ""
: streamId;
mappingDiv.innerHTML = `
<div style="margin-bottom: 5px;">
<label style="font-size: 1em; margin-bottom: 6px; display: block; font-weight:500;" data-i18n="streamIdMappings.ruleTitle">Stream Mapping Rule</label>
<div class="flex-row" style="align-items: center; margin-bottom: 6px;">
<input type="text" value="${initialStreamId}" class="mapping-stream-id" data-i18n="[placeholder]streamIdMappings.streamIdPlaceholder;[title]streamIdMappings.streamIdTooltip" placeholder="Stream ID" title="Enter the VDO.Ninja Stream ID to match.">
<input type="text" value="${label}" class="mapping-label" data-i18n="[placeholder]streamIdMappings.labelPlaceholder;[title]streamIdMappings.labelTooltip" placeholder="Label (Optional)" title="Enter the VDO.Ninja Label to match (optional).">
<select class="mapping-match-type" data-i18n="[title]streamIdMappings.matchTypeTooltip" title="How to match stream">
<option value="streamId" ${matchType === "streamId" ? "selected" : ""} data-i18n="streamIdMappings.matchType.idOnly">ID Only</option>
<option value="label" ${matchType === "label" ? "selected" : ""} data-i18n="streamIdMappings.matchType.labelOnly">Label Only</option>
<option value="both" ${matchType === "both" ? "selected" : ""} data-i18n="streamIdMappings.matchType.bothRequired">Both Required</option>
<option value="either" ${matchType === "either" ? "selected" : ""} data-i18n="streamIdMappings.matchType.eitherMatch">Either Match</option>
</select>
<select class="mapping-scene-name" data-i18n="[title]streamIdMappings.targetSceneTooltip" title="Target OBS Scene">
<option value="" data-i18n="streamIdMappings.selectSceneOption">Select target scene...</option>
</select>
<button class="remove-mapping-btn" data-i18n="[title]streamIdMappings.removeRuleTooltip" title="Remove this mapping rule">×</button>
</div>
<div class="flex-row" style="gap: 15px;">
<label class="checkbox-label" style="margin-bottom: 0;">
<input type="checkbox" class="mapping-clone-to-main" ${shouldClone ? "checked" : ""}>
<span data-i18n="streamIdMappings.cloneToMainSceneLabel">Clone to main scene</span>
</label>
<label class="checkbox-label" style="margin-bottom: 0;">
<input type="checkbox" class="mapping-switch-to-scene" ${shouldSwitch ? "checked" : ""}>
<span data-i18n="streamIdMappings.switchToSceneOnAddLabel">Switch to scene on add</span>
</label>
</div>
<small style="color: #8a8a9a; font-size: 0.85em; display: block; margin-top: 4px;" data-i18n="streamIdMappings.ruleDescription">
Define how incoming streams are routed to OBS scenes.
</small>
</div>
`;
streamMappingsDiv.appendChild(mappingDiv);
if (window.translation) {
mappingDiv.querySelectorAll('[data-i18n]').forEach(element => {
const keyAttr = element.getAttribute('data-i18n');
const keys = keyAttr.split(';').map(k => k.trim()).filter(k => k);
keys.forEach(keyInstruction => {
if (keyInstruction.startsWith('[placeholder]')) {
element.placeholder = getTranslation(keyInstruction.substring('[placeholder]'.length));
} else if (keyInstruction.startsWith('[title]')) {
element.title = getTranslation(keyInstruction.substring('[title]'.length));
} else {
element.innerHTML = getTranslation(keyInstruction);
}
});
});
}
const sceneDropdown = mappingDiv.querySelector(".mapping-scene-name");
populateSceneDropdown(obsScenes, sceneDropdown, "streamIdMappings.selectSceneOption");
if (
sceneName &&
obsScenes.some((scene) => scene.sceneName === sceneName)
) {
sceneDropdown.value = sceneName;
}
const removeBtn = mappingDiv.querySelector(".remove-mapping-btn");
removeBtn.addEventListener("click", () => {
mappingDiv.remove();
saveSettings();
});
const inputs = mappingDiv.querySelectorAll("input, select");
inputs.forEach((input) => {
input.addEventListener("change", saveSettings);
});
}
// Setup stream mapping UI
function setupStreamMappingUI() {
const addStreamMappingBtn = document.getElementById(
"addStreamMappingBtn"
);
addStreamMappingBtn.addEventListener("click", () => {
addNewStreamMapping();
});
loadStreamMappings();
}
// Load stream mappings from localStorage
function loadStreamMappings() {
const mappingsJson = localStorage.getItem("obsNinjaStreamMappings");
if (mappingsJson) {
try {
const mappings = JSON.parse(mappingsJson);
mappings.forEach((mapping) => {
addNewStreamMapping(
mapping.streamId,
mapping.label,
mapping.sceneName,
mapping.matchType,
mapping.cloneToMain !== undefined ? mapping.cloneToMain : true,
mapping.switchToScene !== undefined
? mapping.switchToScene
: false
);
});
if (obsScenes && obsScenes.length > 0) {
updateSceneDropdowns();
}
if (window.translation) {
logMessage(`Loaded ${mappings.length} stream mappings.`, "logMessages.loadedStreamMappings", { count: mappings.length });
} else {
console.log(`Loaded ${mappings.length} stream mappings.`);
}
} catch (e) {
if (window.translation) {
logMessage(
`Error loading stream mappings from localStorage: ${e.message}.`,
"logMessages.errorLoadingStreamMappings", { message: e.message }
);
} else {
console.error(`Error loading stream mappings from localStorage: ${e.message}`);
}
}
}
}
// Get stream mappings from UI
function getStreamMappings() {
const mappings = [];
document.querySelectorAll(".stream-mapping").forEach((div) => {
const streamIdInput = div.querySelector(".mapping-stream-id");
const labelInput = div.querySelector(".mapping-label");
const matchTypeSelect = div.querySelector(".mapping-match-type");
const sceneNameSelect = div.querySelector(".mapping-scene-name");
const cloneToMainCheckbox = div.querySelector(
".mapping-clone-to-main"
);
const switchToSceneCheckbox = div.querySelector(
".mapping-switch-to-scene"
);
if (
streamIdInput &&
labelInput &&
matchTypeSelect &&
sceneNameSelect &&
cloneToMainCheckbox &&
switchToSceneCheckbox
) {
const streamId = streamIdInput.value.trim();
const label = labelInput.value.trim();
const matchType = matchTypeSelect.value;
const sceneName = sceneNameSelect.value.trim();
const cloneToMain = cloneToMainCheckbox.checked;
const switchToScene = switchToSceneCheckbox.checked;
if (sceneName && (streamId || label)) {
mappings.push({
id: div.dataset.mappingId || generateUUID(),
streamId,
label,
matchType,
sceneName,
cloneToMain,
switchToScene,
});
}
} else {
logMessage(
"Warning: Could not find all expected elements in a stream mapping UI div.",
"logMessages.warningStreamMappingElementsNotFound"
);
}
});
return mappings;
}
// Setup scene layout config UI
function setupLayoutConfigUI() {
if (addLayoutConfigBtn) {
addLayoutConfigBtn.addEventListener('click', () => {
addNewLayoutConfigUI();
updateLayoutConfigsListEmptyState();
});
}
loadLayoutConfigs();
}
// Create slider with number input
function createSliderWithNumberInput(idPrefix, labelKey, min, max, step, value, configId, propertyName) {
const container = document.createElement('div');
container.className = 'slider-container';
const label = document.createElement('label');
label.htmlFor = `layout-slider-${propertyName}-${configId}`;
label.textContent = (getTranslation(labelKey) ? getTranslation(labelKey) : labelKey) + ":";
label.dataset.i18nLabel = labelKey;
label.className = 'slider-label';
const slider = document.createElement('input');
slider.type = 'range';
slider.id = `layout-slider-${propertyName}-${configId}`;
slider.min = min;
slider.max = max;
slider.step = step;
slider.value = value;
slider.dataset.propertyName = propertyName;
const numberInput = document.createElement('input');
numberInput.type = 'number';
numberInput.id = `layout-number-${propertyName}-${configId}`;
numberInput.min = min;
numberInput.max = max;
numberInput.step = step;
numberInput.value = value;
numberInput.dataset.propertyName = propertyName;
slider.addEventListener('input', (event) => {
numberInput.value = event.target.value;
saveLayoutConfigsAndUpdateScene(configId);
});
numberInput.addEventListener('input', (event) => {
slider.value = event.target.value;
saveLayoutConfigsAndUpdateScene(configId);
});
numberInput.addEventListener('change', (event) => {
if (parseFloat(event.target.value) < min) event.target.value = min;
if (parseFloat(event.target.value) > max) event.target.value = max;
slider.value = event.target.value;
saveLayoutConfigsAndUpdateScene(configId);
});
container.appendChild(label);
container.appendChild(slider);
container.appendChild(numberInput);
return container;
}
// Save layout configs and update relevant scenes
function saveLayoutConfigsAndUpdateScene(configId) {
saveLayoutConfigs(); // This updates the global sceneLayoutConfigs array
const changedConfig = sceneLayoutConfigs.find(c => c.id === configId);
if (changedConfig && changedConfig.sceneName) {
triggerLayoutUpdateForScene(changedConfig.sceneName); // Update the scene that was directly edited
// If the changed scene was a GRID scene, check if other Reaction/Highlight scenes
// were using it as a fallback and trigger updates for them.
if (changedConfig.layoutType === 'grid') {
const changedGridSceneName = changedConfig.sceneName;
sceneLayoutConfigs.forEach(otherConfig => {
if (otherConfig.id !== configId &&
(otherConfig.layoutType === 'reaction' || otherConfig.layoutType === 'highlight') &&
otherConfig.sceneName) {
// Determine if 'otherConfig.sceneName' would use 'changedGridSceneName' for fallback.
// The current fallback logic in applyReactionOrHighlightLayoutShared picks the *first* // available grid scene from sceneLayoutConfigs.
const firstPotentialGridFallback = sceneLayoutConfigs.find(cfg => cfg.layoutType === 'grid' && cfg.sceneName);
if (firstPotentialGridFallback && firstPotentialGridFallback.sceneName === changedGridSceneName) {
// If the changed grid scene IS the primary candidate for fallback,
// then trigger an update for this reaction/highlight scene.
// It will re-evaluate if it needs fallback and pick up the new grid settings.
logMessage(`Grid settings for '${changedGridSceneName}' (primary fallback candidate) changed. Triggering update for Reaction/Highlight scene '${otherConfig.sceneName}'.`,
"logMessages.obs.gridFallbackSourceChanged",
{ sourceGridScene: changedGridSceneName, dependentScene: otherConfig.sceneName });
triggerLayoutUpdateForScene(otherConfig.sceneName);
}
}
});
}
}
}
// Create checkbox
function createCheckbox(id, labelKey, checked, configId, propertyName) {
const labelElement = document.createElement('label');
labelElement.className = 'checkbox-label';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `layout-${propertyName}-${configId}`;
checkbox.checked = checked;
checkbox.dataset.propertyName = propertyName;
const span = document.createElement('span');
span.textContent = getTranslation(labelKey) ? getTranslation(labelKey) : labelKey;
span.dataset.i18nLabel = labelKey;
checkbox.addEventListener('change', () => {
saveLayoutConfigsAndUpdateScene(configId);
});
labelElement.appendChild(checkbox);
labelElement.appendChild(span);
return labelElement;
}
// Add new layout config to UI
function addNewLayoutConfigUI(config = {}) {
const configId = config.id || generateUUID();
const configDiv = document.createElement('div');
configDiv.className = 'layout-config-item';
configDiv.dataset.layoutConfigId = configId;
const topRow = document.createElement('div');
topRow.className = 'flex-row';
topRow.innerHTML = `
<label for="layout-scene-${configId}" data-i18n="sceneLayouts.sceneLabel">Scene:</label>
<select id="layout-scene-${configId}" class="layout-scene-select">
<option value="" data-i18n="sceneLayouts.selectSceneOption">Select Scene...</option>
</select>
<label for="layout-type-${configId}" data-i18n="sceneLayouts.layoutLabel">Layout:</label>
<select id="layout-type-${configId}" class="layout-type-select">
<option value="" data-i18n="sceneLayouts.selectLayoutOption">Select Layout...</option>
<option value="grid" data-i18n="sceneLayouts.layoutTypes.grid">Grid</option>
<option value="reaction" data-i18n="sceneLayouts.layoutTypes.reaction">Reaction</option>
<option value="highlight" data-i18n="sceneLayouts.layoutTypes.highlight">Highlight</option>
</select>
<button class="remove-layout-btn" data-i18n="[title]sceneLayouts.removeLayoutTooltip" title="Remove this layout configuration">×</button>
`;
configDiv.appendChild(topRow);
const controlsContainer = document.createElement('div');
controlsContainer.className = 'layout-specific-controls';
controlsContainer.id = `layout-controls-${configId}`;
controlsContainer.style.display = 'none';
configDiv.appendChild(controlsContainer);
layoutConfigsListDiv.appendChild(configDiv);
const sceneSelect = configDiv.querySelector('.layout-scene-select');
const layoutSelect = configDiv.querySelector('.layout-type-select');
populateSceneDropdownWithOptionsDisabled(obsScenes, sceneSelect, "sceneLayouts.selectSceneOption", configId);
if (config.sceneName && obsScenes.some(s => s.sceneName === config.sceneName)) {
sceneSelect.value = config.sceneName;
} else if (config.sceneName) {
logMessage(`Saved scene '${config.sceneName}' for layout not found in current OBS scenes.`, "logMessages.layoutSceneNotFound", {sceneName: config.sceneName});
}
if (config.layoutType) {
layoutSelect.value = config.layoutType;
updateLayoutSpecificControls(configId, config.layoutType, controlsContainer, config);
}
if (window.translation) {
configDiv.querySelectorAll('[data-i18n]').forEach(element => {
const keyAttr = element.getAttribute('data-i18n');
const keys = keyAttr.split(';').map(k => k.trim()).filter(k => k);
keys.forEach(keyInstruction => {
if (keyInstruction.startsWith('[placeholder]')) {
element.placeholder = getTranslation(keyInstruction.substring('[placeholder]'.length));
} else if (keyInstruction.startsWith('[title]')) {
element.title = getTranslation(keyInstruction.substring('[title]'.length));
} else {
element.innerHTML = getTranslation(keyInstruction);
}
});
});
}
configDiv.querySelector('.remove-layout-btn').addEventListener('click', () => {
const sceneOfRemovedLayout = configDiv.querySelector('.layout-scene-select').value;
configDiv.remove();
saveLayoutConfigs();
if (sceneOfRemovedLayout) {
triggerLayoutUpdateForScene(sceneOfRemovedLayout);
}
updateLayoutConfigsListEmptyState();
updateSceneDropdowns();
});
sceneSelect.addEventListener('change', (event) => {
const newSceneName = event.target.value;
const currentConfigId = configId;
const isSceneUsedByOther = sceneLayoutConfigs.some(cfg =>
cfg.id !== currentConfigId && cfg.sceneName === newSceneName && newSceneName !== ""
);
if (isSceneUsedByOther) {
logMessage(`Scene '${newSceneName}' is already configured for another layout. Reverting selection.`, "logMessages.sceneAlreadyConfiguredError", {sceneName: newSceneName});
const previousValue = sceneLayoutConfigs.find(c => c.id === currentConfigId)?.sceneName || "";
event.target.value = previousValue;
if(window.translation) {
alert(getTranslation('sceneLayouts.sceneAlreadyConfiguredAlert', {sceneName: newSceneName}));
} else {
alert(`Scene '${newSceneName}' is already in use by another layout configuration.`);
}
return;
}
saveLayoutConfigsAndUpdateScene(configId);
updateSceneDropdowns();
});
layoutSelect.addEventListener('change', () => {
updateLayoutSpecificControls(configId, layoutSelect.value, controlsContainer, config);
saveLayoutConfigsAndUpdateScene(configId);
});
return configId;
}
// Update layout specific controls
function updateLayoutSpecificControls(configId, layoutType, container, currentConfig = {}) {
container.innerHTML = '';
if (!layoutType) {
container.style.display = 'none';
return;
}
container.style.display = 'block';
if (layoutType === 'grid') {
container.appendChild(createSliderWithNumberInput(`grid-margin-${configId}`, 'sceneLayouts.controls.margin', 0, 500, 1, currentConfig.margin ?? DEFAULT_GRID_MARGIN, configId, 'margin'));
container.appendChild(createSliderWithNumberInput(`grid-spacing-${configId}`, 'sceneLayouts.controls.spacing', 0, 200, 1, currentConfig.spacing ?? DEFAULT_GRID_SPACING, configId, 'spacing'));
container.appendChild(createSliderWithNumberInput(`grid-offsetx-${configId}`, 'sceneLayouts.controls.offsetX', -1000, 1000, 1, currentConfig.offsetX ?? DEFAULT_GRID_X_OFFSET, configId, 'offsetX'));
container.appendChild(createSliderWithNumberInput(`grid-offsety-${configId}`, 'sceneLayouts.controls.offsetY', -1000, 1000, 1, currentConfig.offsetY ?? DEFAULT_GRID_Y_OFFSET, configId, 'offsetY'));
container.appendChild(createCheckbox(`grid-split-${configId}`, 'sceneLayouts.controls.gridSplitScreenTwoCameras', currentConfig.splitScreenTwoCameras ?? DEFAULT_GRID_SPLIT_SCREEN, configId, 'splitScreenTwoCameras'));
} else if (layoutType === 'reaction' || layoutType === 'highlight') {
container.appendChild(createSliderWithNumberInput(`rh-spacing-${configId}`, 'sceneLayouts.controls.spacing', 0, 200, 1, currentConfig.spacing ?? DEFAULT_REACTION_HIGHLIGHT_SPACING, configId, 'spacing'));
container.appendChild(createSliderWithNumberInput(`rh-offsetx-${configId}`, 'sceneLayouts.controls.offsetX', -1000, 1000, 1, currentConfig.offsetX ?? DEFAULT_REACTION_HIGHLIGHT_X_OFFSET, configId, 'offsetX'));
container.appendChild(createSliderWithNumberInput(`rh-offsety-${configId}`, 'sceneLayouts.controls.offsetY', -1000, 1000, 1, currentConfig.offsetY ?? DEFAULT_REACTION_HIGHLIGHT_Y_OFFSET, configId, 'offsetY'));
container.appendChild(createCheckbox(`rh-distribute-${configId}`, 'sceneLayouts.controls.distributeCameras', currentConfig.distributeCameras ?? DEFAULT_REACTION_HIGHLIGHT_DISTRIBUTE_CAMERAS, configId, 'distributeCameras'));
}
if (window.translation) {
container.querySelectorAll('[data-i18n-label]').forEach(label => {
if(label.dataset.i18nLabel && getTranslation(label.dataset.i18nLabel)) {
label.textContent = getTranslation(label.dataset.i18nLabel) + ":";
}
});
container.querySelectorAll('.checkbox-label span[data-i18n-label]').forEach(span => {
if(span.dataset.i18nLabel && getTranslation(span.dataset.i18nLabel)) {
span.textContent = getTranslation(span.dataset.i18nLabel);
}
});
}
}
// Save layout configs
function saveLayoutConfigs() {
const configs = [];
let sceneNameError = false;
const usedSceneNames = new Set();
document.querySelectorAll('.layout-config-item').forEach(div => {
const configId = div.dataset.layoutConfigId;
const sceneSelect = div.querySelector(`#layout-scene-${configId}`);
const layoutSelect = div.querySelector(`#layout-type-${configId}`);
if (sceneSelect && layoutSelect && sceneSelect.value && layoutSelect.value) {
const sceneName = sceneSelect.value;
if (usedSceneNames.has(sceneName)) {
logMessage(`Error saving layouts: Scene '${sceneName}' is configured multiple times. Please ensure each scene has only one layout.`, "logMessages.errorSavingLayoutsDuplicateScene", {sceneName});
sceneNameError = true;
}
usedSceneNames.add(sceneName);
const currentConfig = {
id: configId,
sceneName: sceneName,
layoutType: layoutSelect.value
};
const controlsContainer = div.querySelector(`#layout-controls-${configId}`);
if (controlsContainer) {
controlsContainer.querySelectorAll('input[type="range"], input[type="number"], input[type="checkbox"]').forEach(input => {
const propertyName = input.dataset.propertyName;
if (propertyName) {
if (input.type === 'checkbox') {
currentConfig[propertyName] = input.checked;
} else if (input.type === 'number' || input.type === 'range') {
currentConfig[propertyName] = parseFloat(input.value);
if (isNaN(currentConfig[propertyName])) {
if (input.type === 'range') currentConfig[propertyName] = parseFloat(input.min);
else currentConfig[propertyName] = 0;
}
}
}
});
}
configs.push(currentConfig);
}
});
if (sceneNameError) {
console.warn("Duplicate scene names found in layout configurations. Saving anyway, but this might lead to unexpected behavior.");
}
sceneLayoutConfigs = configs;
localStorage.setItem('obsNinjaSceneLayoutConfigs', JSON.stringify(configs));
if (!sceneNameError) {
logMessage(`Saved ${configs.length} scene layout configurations.`, "logMessages.savedLayoutConfigs", {count: configs.length});
}
}
// Load layout configs
function loadLayoutConfigs() {
const savedConfigsJson = localStorage.getItem('obsNinjaSceneLayoutConfigs');
layoutConfigsListDiv.innerHTML = '';
sceneLayoutConfigs = [];
if (savedConfigsJson) {
try {
const savedConfigs = JSON.parse(savedConfigsJson);
savedConfigs.forEach(config => {
addNewLayoutConfigUI(config);
});
sceneLayoutConfigs = savedConfigs;
logMessage(`Loaded ${savedConfigs.length} scene layout configurations.`, "logMessages.loadedLayoutConfigs", {count: savedConfigs.length});
} catch (e) {
logMessage(`Error loading scene layout configurations: ${e.message}`, "logMessages.errorLoadingLayoutConfigs", {message: e.message});
}
}
updateLayoutConfigsListEmptyState();
updateSceneDropdowns();
}
// Get layout config for a scene
function getLayoutConfigForScene(sceneName) {
return sceneLayoutConfigs.find(config => config.sceneName === sceneName);
}
// Load general settings
function loadSettings() {
const settingsJson = localStorage.getItem("obsNinjaSettings");
let savedLanguage = 'en';
if (settingsJson) {
try {
const settings = JSON.parse(settingsJson);
obsWsUrlInput.value = settings.obsWsUrl || "ws://localhost:4455";
obsWsPasswordInput.value = settings.obsWsPassword || "";
cameraPrefixInput.value = settings.cameraPrefix || "VDO";
reactionSubPrefixInput.value = settings.reactionSubPrefix || "Screen";
highlightSubPrefixInput.value =
settings.highlightSubPrefix || "Highlight";
vdoNinjaBaseUrlInput.value =
settings.vdoNinjaBaseUrl || "https://vdo.ninja";
vdoNinjaRoomInput.value = settings.vdoNinjaRoom || "";
vdoNinjaPasswordInput.value = settings.vdoNinjaPassword || "";
vdoNinjaStreamIdsInput.value = settings.vdoNinjaStreamIds || "";
initialSelectedSourceCreationScenes = settings.selectedSourceCreationScenes || [];
sourceSizingSelect.value = settings.sourceSizing || "defaultSize";
sourceCodecSelect.value = settings.sourceCodec || "";
autoAddSourcesCheckbox.checked =
settings.autoAddSources !== false;
autoRemoveSourcesCheckbox.checked =
settings.autoRemoveSources !== false;
const screenShareWidthInput =
document.getElementById("screenShareWidth");
const screenShareHeightInput =
document.getElementById("screenShareHeight");
if (screenShareWidthInput)
screenShareWidthInput.value = settings.screenShareWidth || "1920";
if (screenShareHeightInput)
screenShareHeightInput.value =
settings.screenShareHeight || "1080";
savedLanguage = settings.language || 'en';
languageSelector.value = savedLanguage;
if (settings.customCSS && customCssInput && customUserCssStyleTag) {
customCssInput.value = settings.customCSS;
applyCustomCss(settings.customCSS);
}
if (window.translation) {
logMessage("Settings loaded from localStorage.", "logMessages.settingsLoaded");
} else {
console.log("Settings loaded from localStorage.");
}
} catch (e) {
if (window.translation) {
logMessage(
`Error loading settings from localStorage: ${e.message}. Using defaults.`,
"logMessages.errorLoadingSettings", { message: e.message }
);
} else {
console.error(`Error loading settings from localStorage: ${e.message}. Using defaults.`);
}
cameraPrefixInput.value = "VDO";
reactionSubPrefixInput.value = "Screen";
highlightSubPrefixInput.value = "Highlight";
vdoNinjaBaseUrlInput.value = "https://vdo.ninja";
sourceSizingSelect.value = "defaultSize";
sourceCodecSelect.value = "";
initialSelectedSourceCreationScenes = [];
}
} else {
if (window.translation) {
logMessage("No saved settings found. Using default values.", "logMessages.noSavedSettings");
} else {
console.log("No saved settings found. Using default values.");
}
cameraPrefixInput.value = "VDO";
reactionSubPrefixInput.value = "Screen";
highlightSubPrefixInput.value = "Highlight";
vdoNinjaBaseUrlInput.value = "https://vdo.ninja";
sourceSizingSelect.value = "defaultSize";
sourceCodecSelect.value = "";
initialSelectedSourceCreationScenes = [];
}
setupStreamMappingUI();
return savedLanguage;
}
// Update active streams list
function updateStreamList() {
if (!window.translation) return;
if (Object.keys(activeStreams).length === 0) {
streamListContainer.innerHTML =
`<div class="stream-item" style="text-align:center; background-color:transparent; border:none; color:#888;">${getTranslation("activeStreams.noActiveStreams")}</div>`;
return;
}
streamListContainer.innerHTML = "";
for (const streamId in activeStreams) {
if (Object.hasOwnProperty.call(activeStreams, streamId)) {
const stream = activeStreams[streamId];
const streamDiv = document.createElement("div");
streamDiv.className = "stream-item";
const targetInfo = getTargetSceneForStream(streamId, stream.label);
let targetSceneNameForDisplay = targetInfo.scene;
let targetSceneTagKey = "activeStreams.mappedSceneTag";
const firstSourceCreationScene = getTargetScene();
if (!targetSceneNameForDisplay) {
targetSceneNameForDisplay = firstSourceCreationScene;
targetSceneTagKey = targetSceneNameForDisplay ? "activeStreams.defaultSceneTag" : "activeStreams.notSet";
} else if (targetSceneNameForDisplay === firstSourceCreationScene){
targetSceneTagKey = "activeStreams.defaultSceneTag";
}
const isHighlighted = highlightedStreamId === streamId;
const isCurrentlyScreenSharingThisStream = screenShareId === streamId;
let streamItemHTML = `
<div style="font-weight: 600; font-size: 1.05em; color: #E0E0E0;">${
stream.label || streamId
}</div>
<small>${getTranslation("activeStreams.streamIdLabel", {id: streamId})}${
stream.label ? ` | ${getTranslation("activeStreams.labelLabel", {label: stream.label})}` : ""
}</small>
<small style="display: block; color: #A0A0B0;">
${getTranslation("activeStreams.targetSceneLabelText", {sceneName: targetSceneNameForDisplay || getTranslation("activeStreams.notSet")})}
${targetSceneNameForDisplay ? `(${getTranslation(targetSceneTagKey)})` : ""}
</small>
<div style="margin-top: 2px; font-size: 0.9em;">
${
stream.sourceCreated
? `<span style="color:#28A745">${getTranslation("activeStreams.addedToObs")}</span>`
: `<span style="color:#FFC107">${getTranslation("activeStreams.notInObs")}</span>`
}
</div>
<div style="margin-top: 8px;" class="flex-row">`;
streamItemHTML += `
<button class="add-stream-btn" data-stream-id="${streamId}"
style="background-color: ${
stream.sourceCreated ? "#F44336" : "#4C80AF"
};">
${
stream.sourceCreated
? getTranslation("activeStreams.buttons.removeFromObs")
: getTranslation("activeStreams.buttons.addToObs")
}
</button>`;
if (stream.sourceCreated) {
streamItemHTML += `
<button class="highlight-btn" data-stream-id="${streamId}"
style="background-color: ${
isHighlighted ? "#F44336" : "#4CAF50"
};">
${
isHighlighted
? getTranslation("activeStreams.buttons.unhighlight")
: getTranslation("activeStreams.buttons.highlight")
}
</button>`;
}
if (stream.sourceCreated) {
streamItemHTML += `
<button class="screen-share-btn" data-stream-id="${streamId}"
style="background-color: ${
isCurrentlyScreenSharingThisStream
? "#F44336"
: "#9C27B0"
};">
${
isCurrentlyScreenSharingThisStream
? getTranslation("activeStreams.buttons.stopScreenShare")
: getTranslation("activeStreams.buttons.screenShare")
}
</button>`;
}
streamItemHTML += `</div>`;
streamDiv.innerHTML = streamItemHTML;
streamListContainer.appendChild(streamDiv);
const addRemoveBtn = streamDiv.querySelector(".add-stream-btn");
if (addRemoveBtn) {
addRemoveBtn.addEventListener("click", () => {
if (stream.sourceCreated) {
removeStreamFromObs(streamId);
} else {
addStreamToObs(streamId, stream.label, targetInfo);
}
});
}
const highlightBtn = streamDiv.querySelector(".highlight-btn");
if (highlightBtn) {
highlightBtn.addEventListener("click", async () => {
await handleHighlightClick(streamId, stream.label, targetInfo);
});
}
const screenShareBtn = streamDiv.querySelector(".screen-share-btn");
if (screenShareBtn) {
screenShareBtn.addEventListener("click", async () => {
await handleScreenShareClick(streamId, stream.label);
});
}
}
}
}
// Handle highlight click
async function handleHighlightClick(
clickedStreamId,
clickedStreamLabel,
clickedTargetInfo
) {
if (!obsConnected || !obs) {
logMessage("Cannot highlight: Not connected to OBS.", "logMessages.obs.cannotHighlightNotConnected");
return;
}
if (screenShareId) {
logMessage(
`A screen share is active (${screenShareId}). Stopping it before highlighting.`,
"logMessages.obs.stoppingScreenShareForHighlight", { id: screenShareId }
);
await removeScreenShareFromObs(screenShareId);
}
await toggleHighlight(
clickedStreamId,
clickedStreamLabel,
clickedTargetInfo
);
}
// Handle screen share click
async function handleScreenShareClick(streamId, streamLabel) {
const clickedStreamIdForScreenShare = streamId;
const currentActiveScreenShareGlobal = screenShareId;
if (currentActiveScreenShareGlobal === clickedStreamIdForScreenShare) {
await removeScreenShareFromObs(clickedStreamIdForScreenShare);
} else {
if (!obsConnected || !obs) {
logMessage("Cannot start screen share: Not connected to OBS.", "logMessages.obs.cannotScreenShareNotConnected");
return;
}
if (highlightedStreamId) {
logMessage(
`A camera is highlighted (legacy: ${highlightedStreamId}). Unhighlighting it before starting screen share.`,
"logMessages.obs.unhighlightingForScreenShare", { id: highlightedStreamId }
);
const oldHighlightDetails = activeStreams[highlightedStreamId];
const oldHighlightLabel = oldHighlightDetails
? oldHighlightDetails.label
: "";
const oldHighlightTargetInfo = getTargetSceneForStream(
highlightedStreamId,
oldHighlightLabel
);
await toggleHighlight(
highlightedStreamId,
oldHighlightLabel,
oldHighlightTargetInfo
);
}
await addScreenShareToObs(clickedStreamIdForScreenShare, streamLabel);
}
}
// Add screen share to OBS
async function addScreenShareToObs(streamId, streamLabel) {
const previousGlobalScreenShareId = screenShareId;
if (!obsConnected || !obs) {
logMessage("Cannot add screen share: Not connected to OBS.", "logMessages.obs.cannotAddScreenShareNotConnected");
updateStreamList();
return;
}
const room = vdoNinjaRoomInput.value.trim();
if (!room) {
logMessage(
"Cannot add screen share: VDO.Ninja Room name is required for screen sharing URLs.",
"logMessages.obs.cannotAddScreenShareNoRoom"
);
updateStreamList();
return;
}
screenShareId = streamId;
const width =
parseInt(document.getElementById("screenShareWidth").value) || 1920;
const height =
parseInt(document.getElementById("screenShareHeight").value) || 1080;
const reactionLayoutScenes = sceneLayoutConfigs
.filter(cfg => cfg.layoutType === 'reaction' && cfg.sceneName)
.map(cfg => cfg.sceneName);
if (reactionLayoutScenes.length === 0) {
logMessage("Cannot add screen share: No scenes are configured with a 'Reaction' layout.", "logMessages.obs.cannotAddScreenShareNoReactionLayoutScene");
screenShareId = previousGlobalScreenShareId;
updateStreamList();
return;
}
const primaryTargetSceneForCreation = reactionLayoutScenes[0];
const sourceName = `${getFullReactionPrefix()}_${streamId}:s`;
const baseUrl = getVdoNinjaBaseUrl();
const selectedCodec = sourceCodecSelect.value;
let screenShareUrl = `${baseUrl}/?view=${encodeURIComponent(
streamId
)}:s&solo&room=${encodeURIComponent(room)}`;
if (selectedCodec && selectedCodec !== "") {
screenShareUrl += `&codec=${encodeURIComponent(selectedCodec)}`;
} else {
screenShareUrl += `&codec=vp9`;
}
if (vdoNinjaPasswordInput.value) {
screenShareUrl += `&password=${encodeURIComponent(
vdoNinjaPasswordInput.value
)}`;
}
screenShareUrl += "&cleanoutput&transparent&proaudio";
const inputSettings = {
url: screenShareUrl,
width: width,
height: height,
fps: 30,
reroute_audio: true,
restart_when_active: false,
shutdown: false,
css: customCssInput.value.trim(),
};
logMessage(
`Adding/Updating screen share source '${sourceName}' (URL: ${screenShareUrl}) to Reaction scenes. Primary creation in '${primaryTargetSceneForCreation}'.`,
"logMessages.obs.addingUpdatingScreenShareToReactionScenes", { sourceName, url: screenShareUrl, primaryScene: primaryTargetSceneForCreation, count: reactionLayoutScenes.length }
);
try {
const sourcesResponse = await sendRequest("GetInputList", {}, { suppressNotFound: true });
const existingSourceWithSamePrefix = sourcesResponse.inputs.find(
(input) =>
input.inputName.startsWith(getFullReactionPrefix() + "_") &&
input.inputName.endsWith(":s") &&
input.inputName !== sourceName &&
input.inputKind === "browser_source"
);
if (existingSourceWithSamePrefix) {
logMessage(
`Reconfiguring existing screen share source '${existingSourceWithSamePrefix.inputName}' to be '${sourceName}'.`,
"logMessages.obs.reconfiguringExistingScreenShare", { oldName: existingSourceWithSamePrefix.inputName, newName: sourceName }
);
await sendRequest("SetInputSettings", {
inputName: existingSourceWithSamePrefix.inputName,
inputSettings: inputSettings,
});
if (existingSourceWithSamePrefix.inputName !== sourceName) {
await sendRequest("SetInputName", {
inputName: existingSourceWithSamePrefix.inputName,
newInputName: sourceName,
});
}
logMessage(
`Reconfigured and renamed existing screen share source to '${sourceName}'.`,
"logMessages.obs.reconfiguredRenamedScreenShare", { sourceName }
);
} else {
const exactSourceExists = sourcesResponse.inputs.find(
(input) =>
input.inputName === sourceName &&
input.inputKind === "browser_source"
);
if (exactSourceExists) {
logMessage(
`Screen share source '${sourceName}' already exists. Updating its settings.`,
"logMessages.obs.screenShareExistsUpdating", { sourceName }
);
await sendRequest("SetInputSettings", {
inputName: sourceName,
inputSettings: inputSettings,
});
} else {
logMessage(`Creating new screen share source '${sourceName}' in scene '${primaryTargetSceneForCreation}'.`, "logMessages.obs.creatingNewScreenShareInScene", { sourceName, sceneName: primaryTargetSceneForCreation });
await sendRequest("CreateInput", {
sceneName: primaryTargetSceneForCreation,
inputName: sourceName,
inputKind: "browser_source",
inputSettings,
sceneItemEnabled: true,
});
}
}
const scenesForLayoutUpdate = new Set();
for (const sceneName of reactionLayoutScenes) {
const sceneItemId = await ensureSourceInScene(sceneName, sourceName, width, height, true);
if (sceneItemId) {
const transform = calculateTransform("bestFit", width, height, width, height);
await sendRequest("SetSceneItemTransform", {
sceneName: sceneName,
sceneItemId: sceneItemId,
sceneItemTransform: transform,
});
logMessage(`Ensured and transformed screen share source '${sourceName}' in Reaction scene '${sceneName}'.`, "logMessages.obs.ensuredTransformedScreenShareReaction", { sourceName, sceneName });
scenesForLayoutUpdate.add(sceneName);
}
}
scenesForLayoutUpdate.forEach(sName => { if(sName) triggerLayoutUpdateForScene(sName); });
logMessage(
`Successfully configured screen share for stream ${streamId} (${streamLabel}).`,
"logMessages.obs.successConfigScreenShare", { streamId, label: streamLabel }
);
} catch (error) {
logMessage(
`Error adding/updating screen share source '${sourceName}': ${error.message}.`,
"logMessages.obs.errorAddingUpdatingScreenShare", { sourceName, message: error.message }
);
screenShareId = previousGlobalScreenShareId;
} finally {
updateStreamList();
}
}
// Toggle highlight for a stream
async function toggleHighlight(
clickedStreamId,
clickedStreamLabel,
clickedTargetInfo
) {
if (!obsConnected || !obs) {
logMessage("Cannot toggle highlight: Not connected to OBS.", "logMessages.obs.cannotToggleHighlightNotConnected");
return;
}
const targetSceneForLayoutCheck = clickedTargetInfo ? clickedTargetInfo.scene : getTargetScene();
const layoutConfig = getLayoutConfigForScene(targetSceneForLayoutCheck);
if (layoutConfig && layoutConfig.layoutType === 'highlight') {
logMessage(`Scene '${targetSceneForLayoutCheck}' uses the automated Highlight Layout. Manual highlight changes will apply, but may interact with the automated layout.`, "logMessages.obs.highlightLayoutActiveManualLegacy", {sceneName: targetSceneForLayoutCheck});
}
const previouslyGlobalHighlightedStreamId = highlightedStreamId;
const camPrefix = getFullCameraPrefix();
const hlPrefix = getFullHighlightPrefix();
let sceneToUpdateAfterToggle = targetSceneForLayoutCheck;
if (
previouslyGlobalHighlightedStreamId &&
previouslyGlobalHighlightedStreamId !== clickedStreamId
) {
const oldStreamDetails = activeStreams[previouslyGlobalHighlightedStreamId];
const oldStreamLabel = oldStreamDetails ? oldStreamDetails.label : "";
const oldTargetInfo = getTargetSceneForStream(previouslyGlobalHighlightedStreamId, oldStreamLabel);
const oldHighlightedSourceName = `${hlPrefix}_${previouslyGlobalHighlightedStreamId}`;
const oldOriginalSourceName = `${camPrefix}_${previouslyGlobalHighlightedStreamId}`;
try {
await sendRequest("GetInputSettings", { inputName: oldHighlightedSourceName }, { suppressNotFound: true });
await sendRequest("SetInputName", { inputName: oldHighlightedSourceName, newInputName: oldOriginalSourceName });
logMessage(`Stream ${previouslyGlobalHighlightedStreamId} unhighlighted (renamed to ${oldOriginalSourceName}).`, "logMessages.obs.streamUnhighlighted", {id: previouslyGlobalHighlightedStreamId, newName: oldOriginalSourceName});
} catch (e) {
logMessage(`Could not unhighlight (rename) ${oldHighlightedSourceName}, it might not exist or another error: ${e.message}`, "logMessages.obs.unhighlightError", {sourceName: oldHighlightedSourceName, message: e.message});
}
if (oldTargetInfo && oldTargetInfo.scene) {
sceneToUpdateAfterToggle = oldTargetInfo.scene;
}
}
const clickedOriginalSourceName = `${camPrefix}_${clickedStreamId}`;
const clickedNewHighlightedSourceName = `${hlPrefix}_${clickedStreamId}`;
if (previouslyGlobalHighlightedStreamId === clickedStreamId) {
try {
await sendRequest("GetInputSettings", { inputName: clickedNewHighlightedSourceName }, { suppressNotFound: true });
await sendRequest("SetInputName", { inputName: clickedNewHighlightedSourceName, newInputName: clickedOriginalSourceName });
highlightedStreamId = null;
logMessage(`Stream ${clickedStreamId} successfully unhighlighted (renamed to ${clickedOriginalSourceName}).`, "logMessages.obs.streamSuccessfullyUnhighlighted", {id: clickedStreamId, newName: clickedOriginalSourceName});
} catch (e) {
logMessage(`Could not unhighlight (rename) ${clickedNewHighlightedSourceName}, it might not exist or another error: ${e.message}`, "logMessages.obs.unhighlightError", {sourceName: clickedNewHighlightedSourceName, message: e.message});
highlightedStreamId = null;
}
} else {
try {
await sendRequest("GetInputSettings", { inputName: clickedOriginalSourceName }, { suppressNotFound: true });
await sendRequest("SetInputName", { inputName: clickedOriginalSourceName, newInputName: clickedNewHighlightedSourceName });
highlightedStreamId = clickedStreamId;
logMessage(`Stream ${clickedStreamId} successfully highlighted (renamed to ${clickedNewHighlightedSourceName}).`, "logMessages.obs.streamSuccessfullyHighlighted", {id: clickedStreamId, newName: clickedNewHighlightedSourceName});
} catch (e) {
logMessage(`Could not highlight (rename) ${clickedOriginalSourceName}, it might not exist or another error: ${e.message}`, "logMessages.obs.highlightError", {sourceName: clickedOriginalSourceName, message: e.message});
if (previouslyGlobalHighlightedStreamId && previouslyGlobalHighlightedStreamId !== clickedStreamId) {
highlightedStreamId = previouslyGlobalHighlightedStreamId;
} else {
highlightedStreamId = null;
}
}
}
if (sceneToUpdateAfterToggle) {
await triggerLayoutUpdateForScene(sceneToUpdateAfterToggle);
}
const defaultScene = getTargetScene();
if (clickedTargetInfo && clickedTargetInfo.mapping?.cloneToMain && defaultScene && defaultScene !== sceneToUpdateAfterToggle) {
await triggerLayoutUpdateForScene(defaultScene);
}
updateStreamList();
saveSettings();
}
// Event listener for OBS connect button
obsConnectBtn.addEventListener("click", () => {
if (obsConnected && obs) {
logMessage("Disconnecting from OBS WebSocket...", "logMessages.obs.disconnecting");
if (obs) {
obs.onclose = null;
obs.close();
obs = null;
}
onObsDisconnected();
} else {
connectToOBS();
}
});
// Get target scene for a stream
function getTargetSceneForStream(streamId, streamLabel = "") {
const mappings = getStreamMappings();
const defaultTargetScene = getTargetScene();
for (const mapping of mappings) {
let isMatch = false;
switch (mapping.matchType) {
case "streamId":
isMatch = mapping.streamId && streamId === mapping.streamId;
break;
case "label":
isMatch =
mapping.label && streamLabel && streamLabel === mapping.label;
break;
case "both":
isMatch =
mapping.streamId &&
mapping.label &&
streamId === mapping.streamId &&
streamLabel === mapping.label;
break;
case "either":
isMatch =
(mapping.streamId && streamId === mapping.streamId) ||
(mapping.label &&
streamLabel &&
streamLabel === mapping.label);
break;
}
if (isMatch && mapping.sceneName) {
return { scene: mapping.sceneName, mapping: mapping };
}
}
return { scene: defaultTargetScene, mapping: null };
}
// Update scene dropdowns
function updateSceneDropdowns() {
if (!window.translation) return;
const sourceCreationContainer = document.getElementById("sourceCreationScenesContainer");
if (sourceCreationContainer) {
populateSceneCheckboxes(obsScenes, sourceCreationContainer, initialSelectedSourceCreationScenes);
}
document.querySelectorAll(".mapping-scene-name").forEach(select => {
const oldValue = select.value;
populateSceneDropdown(obsScenes, select, "streamIdMappings.selectSceneOption");
if (oldValue && obsScenes.some(s => s.sceneName === oldValue)) select.value = oldValue; else select.value = "";
});
document.querySelectorAll(".layout-config-item").forEach(layoutItemDiv => {
const configId = layoutItemDiv.dataset.layoutConfigId;
const sceneSelect = layoutItemDiv.querySelector(`#layout-scene-${configId}`);
if (sceneSelect && configId) {
const savedConfig = sceneLayoutConfigs.find(c => c.id === configId);
const targetSceneNameFromStorage = savedConfig ? savedConfig.sceneName : null;
const currentSelectedValueInDropdown = sceneSelect.value;
populateSceneDropdownWithOptionsDisabled(obsScenes, sceneSelect, "sceneLayouts.selectSceneOption", configId);
if (targetSceneNameFromStorage && obsScenes.some(s => s.sceneName === targetSceneNameFromStorage) && !sceneSelect.querySelector(`option[value="${targetSceneNameFromStorage}"]`)?.disabled) {
sceneSelect.value = targetSceneNameFromStorage;
} else if (currentSelectedValueInDropdown && obsScenes.some(s => s.sceneName === currentSelectedValueInDropdown) && !sceneSelect.querySelector(`option[value="${currentSelectedValueInDropdown}"]`)?.disabled) {
sceneSelect.value = currentSelectedValueInDropdown;
} else {
sceneSelect.value = "";
}
}
});
}
// Connect to OBS
async function connectToOBS() {
if (
cameraPrefixInput.value.trim() === ""
) {
logMessage(
"Error: General Camera prefix is required for OBS connection.",
"logMessages.obs.errorCameraPrefixRequired"
);
obsStatusIndicator.classList.add("error");
if (window.translation) obsConnectionStatus.textContent = getTranslation("obsConnection.statusErrorCameraPrefixMissing");
return;
}
let url = obsWsUrlInput.value.trim();
const password = obsWsPasswordInput.value;
if (!url) {
logMessage("Error: OBS WebSocket URL is required.", "logMessages.obs.errorUrlRequired");
obsStatusIndicator.classList.add("error");
if (window.translation) obsConnectionStatus.textContent = getTranslation("obsConnection.statusErrorUrlMissing");
return;
}
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
url = "ws://" + url;
obsWsUrlInput.value = url;
}
if (window.translation) obsConnectionStatus.textContent = getTranslation("obsConnection.statusConnecting");
obsStatusIndicator.classList.remove("connected", "error");
logMessage(`Attempting to connect to OBS WebSocket at ${url}...`, "logMessages.obs.attemptingConnection", { url });
const connectionTimeoutId = setTimeout(() => {
if (
obs &&
obs.readyState !== WebSocket.OPEN &&
obs.readyState !== WebSocket.CONNECTING
) {
logMessage("OBS WebSocket connection attempt timed out.", "logMessages.obs.connectionTimeout");
if (obs) {
try {
obs.close();
} catch (e) {}
obs = null;
}
if (window.translation) {
obsConnectionStatus.textContent = getTranslation("obsConnection.statusErrorTimeout");
obsConnectBtn.textContent = getTranslation("obsConnection.connectButton");
}
obsStatusIndicator.classList.add("error");
obsConnectBtn.classList.remove("connected");
obsConnectBtn.classList.add("disconnected");
}
}, 10000);
try {
obs = new WebSocket(url);
obs.onopen = () => {
logMessage(
"OBS WebSocket connection opened. Waiting for Server Hello...",
"logMessages.obs.connectionOpenedWaitingHello"
);
};
obs.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
if (message.op === 0) {
logMessage("Received Hello from OBS. Sending Identify...", "logMessages.obs.receivedHelloSendingIdentify");
const identifyPayload = {
op: 1,
d: {
rpcVersion: 1,
eventSubscriptions:
(1 << 0) |
(1 << 1) |
(1 << 2) |
(1 << 3) |
(1 << 6) |
(1 << 7) |
(1 << 8) |
(1 << 9),
},
};
if (message.d && message.d.authentication) {
const { challenge, salt } = message.d.authentication;
if (password) {
identifyPayload.d.authentication =
await generateAuthResponse(password, salt, challenge);
logMessage(
"Authentication data prepared for Identify message.",
"logMessages.obs.authDataPrepared"
);
} else {
logMessage(
"Warning: OBS server requires authentication, but no password provided.",
"logMessages.obs.warningAuthRequiredNoPassword"
);
}
}
obs.send(JSON.stringify(identifyPayload));
} else if (message.op === 2) {
clearTimeout(connectionTimeoutId);
logMessage(
"OBS WebSocket Authentication successful! Connection established.",
"logMessages.obs.authSuccessConnected"
);
obsConnected = true;
updateObsConnectButtonText();
obsConnectBtn.classList.add("connected");
obsConnectBtn.classList.remove("disconnected");
if (window.translation) obsConnectionStatus.textContent = getTranslation("obsConnection.statusConnected");
obsStatusIndicator.classList.add("connected");
obsStatusIndicator.classList.remove("error");
onObsConnected();
} else if (message.op === 7) {
const entry =
message.d && message.d.requestId
? requestCallbacks[message.d.requestId]
: null;
if (entry) {
const responseData = message.d;
const requestStatus = responseData.requestStatus;
const requestType =
responseData.requestType || entry.requestType;
if (requestStatus && requestStatus.result === true) {
entry.resolve(responseData.responseData || {});
} else {
const errorMessage = requestStatus && requestStatus.comment ? requestStatus.comment : "Unknown error";
const errorCode = requestStatus && requestStatus.code ? requestStatus.code : "N/A";
const suppressLog =
entry.suppressNotFound && (errorCode === 600 || errorCode === 601 || errorCode === 602 || errorCode === 603 || errorCode === 604);
if (!suppressLog) {
logMessage(
`OBS Request Error (Type: ${requestType}, ID: ${responseData.requestId}): ${errorMessage} (Code: ${errorCode})`,
"logMessages.obs.requestError", { type: requestType, id: responseData.requestId, error: errorMessage, code: errorCode }
);
}
entry.reject(
new Error(
`Request ${requestType} failed: ${errorMessage} (Code: ${errorCode})`
)
);
}
delete requestCallbacks[responseData.requestId];
}
} else if (message.op === 5) {
if (message.d && message.d.eventType) {
if (message.d.eventType === "SceneListChanged") {
logMessage("OBS Event: Scene list changed. Re-fetching scenes.", "logMessages.obs.eventSceneListChanged");
fetchObsScenes();
}
if (["SceneItemCreated", "SceneItemRemoved", "InputRemoved"].includes(message.d.eventType) && message.d.eventData?.sceneName) {
triggerLayoutUpdateForScene(message.d.eventData.sceneName);
}
if (message.d.eventType === "InputNameChanged" && message.d.eventData) {
const oldName = message.d.eventData.oldInputName;
const newName = message.d.eventData.inputName;
logMessage(`OBS Event: Input name changed from '${oldName}' to '${newName}'. Checking relevant scenes for layout updates.`, "logMessages.obs.inputNameChanged", {oldName, newName});
const affectedScenes = new Set();
const camPrefix = getFullCameraPrefix();
const reactionPfx = getFullReactionPrefix();
const highlightPfx = getFullHighlightPrefix();
const isRelevantName = (name) => {
if (!name) return false;
return name.startsWith(camPrefix) ||
(reactionPfx && name.startsWith(reactionPfx)) ||
(highlightPfx && name.startsWith(highlightPfx));
};
if (isRelevantName(oldName) || isRelevantName(newName)) {
sceneLayoutConfigs.forEach(config => {
if (config.sceneName) {
affectedScenes.add(config.sceneName);
}
});
const defaultSceneName = getTargetScene();
if (defaultSceneName && !getLayoutConfigForScene(defaultSceneName)) {
affectedScenes.add(defaultSceneName);
}
}
affectedScenes.forEach(sceneName => {
if(sceneName) triggerLayoutUpdateForScene(sceneName);
});
}
}
}
} catch (error) {
logMessage(
`Error processing OBS WebSocket message: ${error.message}. Data: ${event.data}`,
"logMessages.obs.errorProcessingMessage", { message: error.message, data: event.data }
);
}
};
obs.onerror = (errorEvent) => {
clearTimeout(connectionTimeoutId);
let errorMsg = "Unknown WebSocket error";
if (errorEvent && errorEvent.message) {
errorMsg = errorEvent.message;
} else if (typeof errorEvent === "string") {
errorMsg = errorEvent;
}
logMessage(`OBS WebSocket Error: ${errorMsg}`, "logMessages.obs.webSocketError", { error: errorMsg });
obsStatusIndicator.classList.add("error");
if (window.translation) {
obsConnectionStatus.textContent = getTranslation("obsConnection.statusError");
obsConnectBtn.textContent = getTranslation("obsConnection.connectButton");
}
obsConnectBtn.classList.remove("connected");
obsConnectBtn.classList.add("disconnected");
obsConnected = false;
};
obs.onclose = (event) => {
clearTimeout(connectionTimeoutId);
let reason = "";
if (window.translation) {
if (event.code === 4009) {
reason = getTranslation("logMessages.obs.authFailedReason");
} else if (event.reason) {
reason = event.reason;
} else {
reason = getTranslation("logMessages.obs.connectionClosedReasonCode", {code: event.code || "Unknown", wasClean: event.wasClean ? "" : getTranslation("logMessages.obs.uncleanDisconnection") });
}
} else {
reason = `Code ${event.code || 'Unknown'}`;
}
logMessage(`OBS WebSocket Connection Closed. Reason: ${reason}`, "logMessages.obs.connectionClosed", { reason });
onObsDisconnected();
};
} catch (error) {
clearTimeout(connectionTimeoutId);
logMessage(
`Error creating OBS WebSocket connection: ${error.message}`,
"logMessages.obs.errorCreatingConnection", { message: error.message }
);
if (window.translation) {
obsConnectionStatus.textContent = getTranslation("obsConnection.statusError");
obsConnectBtn.textContent = getTranslation("obsConnection.connectButton");
}
obsStatusIndicator.classList.add("error");
obsConnectBtn.classList.remove("connected");
obsConnectBtn.classList.add("disconnected");
obsConnected = false;
}
}
// Generate auth response for OBS
async function generateAuthResponse(password, salt, challenge) {
const encoder = new TextEncoder();
try {
const secretString = password + salt;
const secretData = encoder.encode(secretString);
let secretHash;
if (window.crypto && window.crypto.subtle) {
const hashBuffer = await window.crypto.subtle.digest(
"SHA-256",
secretData
);
secretHash = new Uint8Array(hashBuffer);
} else {
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))
);
}
const secretBase64 = btoa(String.fromCharCode.apply(null, secretHash));
const authString = secretBase64 + challenge;
const authData = encoder.encode(authString);
let authHash;
if (window.crypto && window.crypto.subtle) {
const hashBuffer = await window.crypto.subtle.digest(
"SHA-256",
authData
);
authHash = new Uint8Array(hashBuffer);
} else {
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))
);
}
return btoa(String.fromCharCode.apply(null, authHash));
} catch (error) {
logMessage(`OBS Authentication generation error: ${error.message}`, "logMessages.obs.authGenerationError", { message: error.message });
throw error;
}
}
// Send request to OBS
function sendRequest(requestType, requestData = {}, options = {}) {
return new Promise((resolve, reject) => {
if (!obsConnected || !obs) {
logMessage(
`Cannot send request '${requestType}': Not connected to OBS.`,
"logMessages.obs.cannotSendRequestNotConnected", { requestType }
);
reject(new Error("Not connected to OBS"));
return;
}
const requestId = generateRequestId(requestType);
requestCallbacks[requestId] = {
resolve,
reject,
requestType,
suppressNotFound: !!options.suppressNotFound,
};
const requestPayload = {
op: 6,
d: {
requestType,
requestId,
requestData,
},
};
try {
obs.send(JSON.stringify(requestPayload));
} catch (error) {
logMessage(
`Error sending OBS request '${requestType}': ${error.message}`,
"logMessages.obs.errorSendingRequest", { requestType, message: error.message }
);
if (requestCallbacks[requestId]) {
const failedEntry = requestCallbacks[requestId];
delete requestCallbacks[requestId];
failedEntry.reject(error);
} else {
reject(error);
}
}
setTimeout(() => {
if (requestCallbacks[requestId]) {
const entry = requestCallbacks[requestId];
delete requestCallbacks[requestId];
logMessage(
`OBS Request '${entry.requestType}' (ID: ${requestId}) timed out.`,
"logMessages.obs.requestTimeout", { requestType: entry.requestType, id: requestId }
);
entry.reject(new Error(`Request timeout for ${entry.requestType}`));
}
}, 5000);
});
}
// Called when OBS connection is established
async function onObsConnected() {
logMessage("OBS Connection fully established. Fetching initial data...", "logMessages.obs.connectionEstablishedFetchingData");
try {
await fetchObsScenes();
const inputsResponse = await sendRequest("GetInputList", { inputKind: "browser_source" });
const hlPrefix = getFullHighlightPrefix();
let foundHighlightedIdOnConnect = null;
if (inputsResponse && inputsResponse.inputs) {
for (const input of inputsResponse.inputs) {
if (hlPrefix && input.inputName.startsWith(hlPrefix + "_")) {
const potentialStreamId = input.inputName.substring((hlPrefix + "_").length);
if (!potentialStreamId.endsWith(':s') && potentialStreamId.length > 0) {
foundHighlightedIdOnConnect = potentialStreamId;
logMessage(`Found pre-existing highlighted source in OBS: '${input.inputName}'. Setting active highlight to stream ID: ${foundHighlightedIdOnConnect}.`, "logMessages.obs.foundPreExistingHighlight", {sourceName: input.inputName, streamId: foundHighlightedIdOnConnect});
break;
}
}
}
}
highlightedStreamId = foundHighlightedIdOnConnect;
updateStreamList();
sceneLayoutConfigs.forEach(config => {
if (config.sceneName && config.layoutType) {
triggerLayoutUpdateForScene(config.sceneName);
}
});
const mainTargetScene = getTargetScene();
if (mainTargetScene && !getLayoutConfigForScene(mainTargetScene) && sourceSizingSelect.value === "autoGrid") {
triggerLayoutUpdateForScene(mainTargetScene);
}
} catch (error) {
logMessage(`Error during post-OBS connection setup: ${error.message}`, "logMessages.obs.errorPostConnectionSetup", { message: error.message });
}
}
// Called when OBS connection is disconnected
function onObsDisconnected() {
logMessage("OBS Connection has been closed or lost.", "logMessages.obs.connectionClosedOrLost");
obsConnected = false;
updateObsConnectButtonText();
obsConnectBtn.classList.remove("connected");
obsConnectBtn.classList.add("disconnected");
if (window.translation) obsConnectionStatus.textContent = getTranslation("obsConnection.statusDisconnected");
obsStatusIndicator.classList.remove("connected", "error");
Object.values(activeStreams).forEach(
(stream) => (stream.sourceCreated = false)
);
updateStreamList();
obsScenes = [];
updateSceneDropdowns();
}
// Fetch OBS scenes
async function fetchObsScenes() {
if (!obsConnected || !obs) {
logMessage("Cannot fetch OBS scenes: Not connected to OBS.", "logMessages.obs.cannotFetchScenesNotConnected");
return;
}
logMessage("Fetching OBS scenes...", "logMessages.obs.fetchingScenes");
try {
const response = await sendRequest("GetSceneList");
if (response && response.scenes) {
obsScenes = response.scenes;
logMessage(`Fetched ${obsScenes.length} scenes from OBS.`, "logMessages.obs.fetchedScenesCount", { count: obsScenes.length });
updateSceneDropdowns();
} else {
logMessage("Failed to fetch OBS scenes or no scenes returned.", "logMessages.obs.failedToFetchScenes");
obsScenes = [];
updateSceneDropdowns();
}
} catch (error) {
logMessage(`Error fetching OBS scenes: ${error.message}`, "logMessages.obs.errorFetchingScenes", { message: error.message });
obsScenes = [];
updateSceneDropdowns();
}
}
// Populate scene checkboxes
function populateSceneCheckboxes(scenesData, containerElement, selectedValues = []) {
if (!window.translation || !containerElement) return;
containerElement.innerHTML = '';
if (!scenesData || scenesData.length === 0) {
const noScenesText = getTranslation("obsTargetSettings.noScenesFound", "Nenhuma cena encontrada");
containerElement.innerHTML = `<span data-i18n="obsTargetSettings.noScenesFound">${noScenesText}</span>`;
return;
}
scenesData.forEach((scene, index) => {
const sceneId = `scene-checkbox-${scene.sceneName.replace(/[^a-zA-Z0-9-_]/g, '')}-${index}`;
const rowDiv = document.createElement('div');
rowDiv.className = 'checkbox-item-row';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = sceneId;
checkbox.value = scene.sceneName;
checkbox.name = 'sourceCreationScene';
if (selectedValues.includes(scene.sceneName)) {
checkbox.checked = true;
}
checkbox.addEventListener('change', () => {
saveSettings();
updateStreamList();
const allCheckedCheckboxes = Array.from(containerElement.querySelectorAll('input[type="checkbox"]:checked'));
if (allCheckedCheckboxes.length > 0) {
const primarySceneForLayoutUpdate = allCheckedCheckboxes[0].value;
if (primarySceneForLayoutUpdate && !getLayoutConfigForScene(primarySceneForLayoutUpdate)) {
triggerLayoutUpdateForScene(primarySceneForLayoutUpdate);
}
}
});
const label = document.createElement('label');
label.htmlFor = sceneId;
label.textContent = scene.sceneName;
rowDiv.appendChild(checkbox);
rowDiv.appendChild(label);
containerElement.appendChild(rowDiv);
});
}
// Populate a scene dropdown (generic)
function populateSceneDropdown(
scenesData,
selectElement,
placeholderKey = "obsTargetSettings.selectSceneOption",
isMultiSelect = false,
selectedValues = []
) {
if (!window.translation || !selectElement) return;
if (selectElement.id === "sourceCreationScenesContainer") {
return;
}
const currentValues = isMultiSelect ? Array.from(selectElement.selectedOptions).map(opt => opt.value) : [selectElement.value];
selectElement.innerHTML = '';
if (placeholderKey && !isMultiSelect) {
const placeholderOption = document.createElement('option');
placeholderOption.value = "";
placeholderOption.textContent = getTranslation(placeholderKey);
selectElement.appendChild(placeholderOption);
}
if (scenesData && scenesData.length > 0) {
scenesData.forEach((scene) => {
const option = document.createElement("option");
option.value = scene.sceneName;
option.textContent = scene.sceneName;
if (isMultiSelect && selectedValues.includes(scene.sceneName)) {
option.selected = true;
}
selectElement.appendChild(option);
});
}
if (isMultiSelect) {
Array.from(selectElement.options).forEach(opt => {
if (currentValues.includes(opt.value)) {
opt.selected = true;
}
});
} else {
if (currentValues[0] && Array.from(selectElement.options).some(opt => opt.value === currentValues[0])) {
selectElement.value = currentValues[0];
} else if (!placeholderKey) {
if (selectElement.options.length > 0) selectElement.value = selectElement.options[0].value;
} else {
selectElement.value = "";
}
}
}
// Populate scene dropdowns for layout config with disabled options
function populateSceneDropdownWithOptionsDisabled(scenesData, selectElement, placeholderKey, currentEditingConfigId) {
if (!window.translation || !selectElement) return;
const currentValue = selectElement.value;
selectElement.innerHTML = '';
const placeholderOption = document.createElement('option');
placeholderOption.value = "";
placeholderOption.textContent = getTranslation(placeholderKey);
selectElement.appendChild(placeholderOption);
const configuredScenesByOthers = new Set();
sceneLayoutConfigs.forEach(cfg => {
if (cfg.id !== currentEditingConfigId && cfg.sceneName && cfg.sceneName !== "") {
configuredScenesByOthers.add(cfg.sceneName);
}
});
if (scenesData && scenesData.length > 0) {
scenesData.forEach((scene) => {
const option = document.createElement("option");
option.value = scene.sceneName;
option.textContent = scene.sceneName;
if (configuredScenesByOthers.has(scene.sceneName)) {
option.disabled = true;
option.textContent += ` (${getTranslation('sceneLayouts.sceneAlreadyConfiguredTooltip', 'Already in use')})`;
}
selectElement.appendChild(option);
});
}
if (currentValue && Array.from(selectElement.options).some(opt => opt.value === currentValue && !opt.disabled)) {
selectElement.value = currentValue;
} else {
selectElement.value = "";
}
}
// Get the primary target scene (first checked checkbox)
function getTargetScene() {
const firstCheckedCheckbox = document.querySelector('#sourceCreationScenesContainer input[type="checkbox"]:checked');
return firstCheckedCheckbox ? firstCheckedCheckbox.value : "";
}
// Get VDO.Ninja view URL
function getVdoNinjaViewUrl(streamId, includeCommonParams = true) {
const room = vdoNinjaRoomInput.value.trim();
const ninjaPassword = vdoNinjaPasswordInput.value;
const baseUrl = getVdoNinjaBaseUrl();
const selectedCodec = sourceCodecSelect.value;
let url = `${baseUrl}/?`;
if (room) {
url += `view=${encodeURIComponent(
streamId
)}&solo&room=${encodeURIComponent(room)}`;
} else {
url += `view=${encodeURIComponent(streamId)}`;
}
if (ninjaPassword) {
url += `&password=${encodeURIComponent(ninjaPassword)}`;
}
if (selectedCodec && selectedCodec !== "") {
url += `&codec=${encodeURIComponent(selectedCodec)}`;
}
if (includeCommonParams) {
url +=
"&cleanoutput&proaudio&ab=160&transparent&autoplay&noheader&webcursor&sl&cover&sl&showmeta";
}
return url;
}
// Initialize VDO.Ninja iframe
function initializeVdoNinjaIframe() {
const room = vdoNinjaRoomInput.value.trim();
const streamIdsInput = vdoNinjaStreamIdsInput.value.trim();
if (!room && !streamIdsInput) {
logMessage(
"VDO.Ninja: Room Name or specific Stream ID(s) must be provided to connect.",
"logMessages.vdoNinja.roomOrStreamIdNeededForConnect"
);
updateVdoNinjaButtonState(false);
return;
}
const baseUrl = getVdoNinjaBaseUrl();
let vdoNinjaUrl = `${baseUrl}/?`;
if (room) {
vdoNinjaUrl += `room=${encodeURIComponent(room)}`;
if (streamIdsInput) {
const viewStreamIds = streamIdsInput
.split(",")
.map((s) => s.trim())
.filter((s) => s)
.join(",");
if (viewStreamIds)
vdoNinjaUrl += `&view=${encodeURIComponent(viewStreamIds)}&solo`;
}
} else if (streamIdsInput) {
const viewStreamIds = streamIdsInput
.split(",")
.map((s) => s.trim())
.filter((s) => s)
.join(",");
if (viewStreamIds) {
vdoNinjaUrl += `view=${encodeURIComponent(viewStreamIds)}`;
if (viewStreamIds.includes(",")) vdoNinjaUrl += "&solo";
} else {
logMessage(
"VDO.Ninja: Stream IDs provided but were empty after trimming.",
"logMessages.vdoNinja.streamIdsEmptyAfterTrim"
);
updateVdoNinjaButtonState(false);
return;
}
}
if (vdoNinjaPasswordInput.value) {
vdoNinjaUrl += `&password=${encodeURIComponent(
vdoNinjaPasswordInput.value
)}`;
}
vdoNinjaUrl +=
"&cleanoutput&dataonly&nocursor&nopush&noaudio&novideo&cors=" +
encodeURIComponent(window.location.origin);
logMessage(`Initializing VDO.Ninja iframe with URL: ${vdoNinjaUrl}`, "logMessages.vdoNinja.initializingIframe", { url: vdoNinjaUrl });
vdoNinjaIframe.src = "about:blank";
vdoNinjaIframe.setAttribute("crossorigin", "anonymous");
setTimeout(() => {
vdoNinjaIframe.src = vdoNinjaUrl;
vdoNinjaLastActivityTime = Date.now();
}, 100);
}
// VDO.Ninja iframe message listener
window.addEventListener(
"message",
(event) => {
const expectedOriginBase = getVdoNinjaBaseUrl();
let parsedExpectedOrigin;
try {
parsedExpectedOrigin = new URL(expectedOriginBase);
} catch (e) {
logMessage(
`Invalid VDO.Ninja base URL in settings: ${expectedOriginBase}`,
"logMessages.vdoNinja.invalidBaseUrl", { url: expectedOriginBase }
);
return;
}
if (
event.origin !== parsedExpectedOrigin.origin ||
event.source !== vdoNinjaIframe.contentWindow
) {
return;
}
const data = event.data;
vdoNinjaLastActivityTime = Date.now();
if (!vdoNinjaConnected) {
vdoNinjaConnected = true;
updateVdoNinjaButtonState(true);
if (vdoNinjaConnectionCheckTimer) {
clearTimeout(vdoNinjaConnectionCheckTimer);
vdoNinjaConnectionCheckTimer = null;
}
logMessage("VDO.Ninja iframe connection established and active.", "logMessages.vdoNinja.iframeConnectedActive");
}
if (data && data.streamID) {
const streamId = data.streamID;
const label = data.label || (window.translation ? getTranslation("vdoNinja.defaultStreamLabel", {id: streamId}) : `Stream ${streamId}`);
if (
(data.action === "view-connection" && data.value === true) ||
data.action === "guest-connected" ||
(data.action === "push-connection" && data.value === true)
) {
logMessage(
`VDO.Ninja stream connected/active: "${label}" (ID: ${streamId})`,
"logMessages.vdoNinja.streamConnectedActive", { label, id: streamId }
);
activeStreams[streamId] = {
label,
sourceCreated: false,
streamId,
uuid: data.UUID || null,
connected: true,
};
updateStreamList();
if (autoAddSourcesCheckbox.checked) {
logMessage(`Auto-adding stream ${streamId} to OBS.`, "logMessages.vdoNinja.autoAddingStream", { id: streamId });
const targetInfo = getTargetSceneForStream(streamId, label);
addStreamToObs(streamId, label, targetInfo);
}
} else if (
(data.action === "view-connection" && data.value === false) ||
(data.action === "push-connection" && data.value === false)
) {
if (activeStreams[streamId]) {
logMessage(
`VDO.Ninja stream disconnected/inactive: "${label}" (ID: ${streamId})`,
"logMessages.vdoNinja.streamDisconnectedInactive", { label, id: streamId }
);
if (
autoRemoveSourcesCheckbox &&
autoRemoveSourcesCheckbox.checked
) {
logMessage(`Auto-removing stream ${streamId} from OBS.`, "logMessages.vdoNinja.autoRemovingStream", { id: streamId });
removeStreamFromObs(streamId);
}
delete activeStreams[streamId];
updateStreamList();
}
} else if (
data.action === "view-connection-info" &&
data.value &&
data.value.label
) {
if (activeStreams[streamId]) {
if (activeStreams[streamId].label !== data.value.label) {
logMessage(
`VDO.Ninja stream label updated for ID ${streamId}: "${data.value.label}" (was "${activeStreams[streamId].label}")`,
"logMessages.vdoNinja.streamLabelUpdated", { id: streamId, newLabel: data.value.label, oldLabel: activeStreams[streamId].label }
);
activeStreams[streamId].label = data.value.label;
updateStreamList();
}
}
}
}
},
false
);
// Start VDO.Ninja connection monitor
function startVdoNinjaConnectionMonitor() {
setInterval(() => {
if (
vdoNinjaConnected &&
Date.now() - vdoNinjaLastActivityTime > 45000
) {
logMessage(
"VDO.Ninja connection lost (no activity from iframe). Attempting to reset.",
"logMessages.vdoNinja.connectionLostResetting"
);
if (window.translation) vdoNinjaConnectionStatus.textContent = getTranslation("vdoNinjaSettings.statusConnectionLost");
vdoNinjaStatusIndicator.classList.add("error");
vdoNinjaStatusIndicator.classList.remove("connected");
disconnectFromVdoNinja();
}
}, 30000);
}
// Add stream to OBS
async function addStreamToObs(
streamId,
streamLabel,
targetInfo = null
) {
if (!obsConnected || !obs) {
logMessage(
`Cannot add stream "${streamLabel}" (${streamId}) to OBS: Not connected to OBS.`,
"logMessages.obs.cannotAddStreamNotConnected", { label: streamLabel, id: streamId }
);
return;
}
const resolvedTargetInfo =
targetInfo || getTargetSceneForStream(streamId, streamLabel);
const allSelectedSourceCreationScenes = [];
document.querySelectorAll('#sourceCreationScenesContainer input[type="checkbox"]:checked').forEach(cb => {
allSelectedSourceCreationScenes.push(cb.value);
});
const primaryTargetSceneName = resolvedTargetInfo.scene || (allSelectedSourceCreationScenes.length > 0 ? allSelectedSourceCreationScenes[0] : "");
const otherScenesToCopyTo = allSelectedSourceCreationScenes.slice(1);
const mappingRule = resolvedTargetInfo.mapping;
if (!primaryTargetSceneName) {
logMessage(
`Cannot add stream "${streamLabel}" (${streamId}): Target OBS scene name is required but not set (no default from checkboxes and no mapping).`,
"logMessages.obs.cannotAddStreamNoTargetSceneName", { label: streamLabel, id: streamId }
);
return;
}
const baseSourceName = `${getFullCameraPrefix()}_${streamId}`;
const highlightedSourceName = `${getFullHighlightPrefix()}_${streamId}`;
let effectiveSourceName = baseSourceName;
let sourceReallyExistsGlobally = false;
try {
await sendRequest("GetInputSettings", { inputName: baseSourceName }, { suppressNotFound: true });
sourceReallyExistsGlobally = true;
effectiveSourceName = baseSourceName;
logMessage(`Found existing standard source '${baseSourceName}' for stream ${streamId}.`, "logMessages.obs.foundExistingStandardSource", { sourceName: baseSourceName, streamId });
} catch (e_base) {
if (highlightedSourceName !== baseSourceName) {
try {
await sendRequest("GetInputSettings", { inputName: highlightedSourceName }, { suppressNotFound: true });
sourceReallyExistsGlobally = true;
effectiveSourceName = highlightedSourceName;
if (highlightedStreamId !== streamId) {
logMessage(`Found existing OBS source '${highlightedSourceName}' which matches highlight naming for connecting stream ${streamId}. Updating internal highlight state.`, "logMessages.obs.foundExistingHighlightForConnectingStream", { sourceName: highlightedSourceName, streamId });
highlightedStreamId = streamId;
}
logMessage(`Found existing highlighted source '${highlightedSourceName}' for stream ${streamId}.`, "logMessages.obs.foundExistingHighlightedSource", { sourceName: highlightedSourceName, streamId });
} catch (e_highlight) {
sourceReallyExistsGlobally = false;
logMessage(`Neither standard ('${baseSourceName}') nor highlighted ('${highlightedSourceName}') source found for stream ${streamId}. Will create new.`, "logMessages.obs.noExistingSourceFoundWillCreate", { baseSourceName, highlightedSourceName, streamId });
}
} else {
sourceReallyExistsGlobally = false;
logMessage(`Standard source '${baseSourceName}' not found for stream ${streamId} (highlight prefix is same). Will create new.`, "logMessages.obs.noExistingStandardSourceSamePrefix", { sourceName: baseSourceName, streamId });
}
}
if (activeStreams[streamId]) {
activeStreams[streamId].sourceCreated = sourceReallyExistsGlobally;
}
const firstSourceCreationSceneFromCheckboxes = getTargetScene();
let shouldCloneThisStreamToMainViaMapping = false;
if (
mappingRule &&
mappingRule.sceneName !== firstSourceCreationSceneFromCheckboxes &&
mappingRule.cloneToMain &&
firstSourceCreationSceneFromCheckboxes
) {
shouldCloneThisStreamToMainViaMapping = true;
}
let shouldSwitchToThisScene = false;
if (mappingRule && mappingRule.switchToScene) {
shouldSwitchToThisScene = true;
}
const vdoNinjaStreamUrl = getVdoNinjaViewUrl(streamId);
let canvasWidth = 1920, canvasHeight = 1080;
try {
const videoSettings = await sendRequest("GetVideoSettings");
if (videoSettings && videoSettings.baseWidth && videoSettings.baseHeight) {
canvasWidth = videoSettings.baseWidth;
canvasHeight = videoSettings.baseHeight;
}
} catch (error) {
logMessage(
`Error getting OBS canvas size: ${error.message}. Using default ${canvasWidth}x${canvasHeight}.`,
"logMessages.obs.errorGettingCanvasSize", { message: error.message, width: canvasWidth, height: canvasHeight }
);
}
const inputSettings = {
url: vdoNinjaStreamUrl,
width: canvasWidth,
height: canvasHeight,
fps: 30,
css: customCssInput.value.trim(),
reroute_audio: true,
restart_when_active: false,
shutdown: false,
};
try {
if (!sourceReallyExistsGlobally) {
effectiveSourceName = baseSourceName;
logMessage(
`Source '${effectiveSourceName}' does not exist globally. Creating it in scene '${primaryTargetSceneName}'.`,
"logMessages.obs.sourceNotGlobalCreating", { sourceName: effectiveSourceName, sceneName: primaryTargetSceneName }
);
await sendRequest("CreateInput", {
sceneName: primaryTargetSceneName,
inputName: effectiveSourceName,
inputKind: "browser_source",
inputSettings,
sceneItemEnabled: true,
});
logMessage(`Global source '${effectiveSourceName}' created and added to scene '${primaryTargetSceneName}'.`, "logMessages.obs.sourceCreatedAddedToScene", {sourceName: effectiveSourceName, sceneName: primaryTargetSceneName});
} else {
logMessage(
`Source '${effectiveSourceName}' already exists globally. Updating its settings. URL: ${vdoNinjaStreamUrl}`,
"logMessages.obs.sourceGlobalUpdatingWithUrl", { sourceName: effectiveSourceName, url: vdoNinjaStreamUrl }
);
await sendRequest("SetInputSettings", {
inputName: effectiveSourceName,
inputSettings,
});
}
await ensureSourceInScene(primaryTargetSceneName, effectiveSourceName, canvasWidth, canvasHeight);
if (activeStreams[streamId]) activeStreams[streamId].sourceCreated = true;
if (shouldCloneThisStreamToMainViaMapping && firstSourceCreationSceneFromCheckboxes && firstSourceCreationSceneFromCheckboxes !== primaryTargetSceneName) {
logMessage(`Cloning source '${effectiveSourceName}' to main scene (from checkboxes) '${firstSourceCreationSceneFromCheckboxes}' due to mapping rule.`, "logMessages.obs.cloningToMainScene", {sourceName: effectiveSourceName, sceneName: firstSourceCreationSceneFromCheckboxes});
await ensureSourceInScene(firstSourceCreationSceneFromCheckboxes, effectiveSourceName, canvasWidth, canvasHeight);
}
for (const otherSceneName of otherScenesToCopyTo) {
if (otherSceneName && otherSceneName !== primaryTargetSceneName) {
logMessage(`Adding source '${effectiveSourceName}' as item to other selected scene '${otherSceneName}'.`, "logMessages.obs.addingSourceToOtherScene", {sourceName: effectiveSourceName, sceneName: otherSceneName});
await ensureSourceInScene(otherSceneName, effectiveSourceName, canvasWidth, canvasHeight);
}
}
const scenesToUpdateLayout = new Set([primaryTargetSceneName]);
if (shouldCloneThisStreamToMainViaMapping && firstSourceCreationSceneFromCheckboxes) scenesToUpdateLayout.add(firstSourceCreationSceneFromCheckboxes);
otherScenesToCopyTo.forEach(scene => { if(scene) scenesToUpdateLayout.add(scene); });
scenesToUpdateLayout.forEach(scene => {
if (scene) triggerLayoutUpdateForScene(scene);
});
if (shouldSwitchToThisScene && mappingRule.sceneName) {
logMessage(`Switching OBS current program scene to '${mappingRule.sceneName}' due to mapping rule.`, "logMessages.obs.switchingProgramScene", { sceneName: mappingRule.sceneName });
const studio = await sendRequest("GetStudioModeEnabled");
if (studio.studioModeEnabled) {
await sendRequest("SetCurrentPreviewScene", { sceneName: mappingRule.sceneName });
await sleep(100);
await sendRequest("TriggerStudioModeTransition");
} else {
await sendRequest("SetCurrentProgramScene", { sceneName: mappingRule.sceneName });
}
}
logMessage(
`Successfully processed stream "${streamLabel}" (${streamId}), effective OBS source: '${effectiveSourceName}'.`,
"logMessages.obs.successfullyProcessedStream", { label: streamLabel, id: streamId, sourceName: effectiveSourceName }
);
} catch (error) {
logMessage(
`Error managing stream '${effectiveSourceName}' ("${streamLabel}") in OBS: ${error.message}`,
"logMessages.obs.errorManagingStream", { sourceName: effectiveSourceName, label: streamLabel, message: error.message }
);
if (activeStreams[streamId]) activeStreams[streamId].sourceCreated = false;
} finally {
updateStreamList();
}
}
// Ensure source is in scene
async function ensureSourceInScene(sceneName, sourceName, canvasWidth, canvasHeight, isScreenShare = false) {
if (!sceneName || !sourceName) return null;
let sceneItemId = null;
try {
const itemInfo = await sendRequest("GetSceneItemId", { sceneName, sourceName }, { suppressNotFound: true });
sceneItemId = itemInfo.sceneItemId;
await sendRequest("SetSceneItemEnabled", { sceneName, sceneItemId, sceneItemEnabled: true });
logMessage(`Source '${sourceName}' found in scene '${sceneName}'. Ensured enabled.`, "logMessages.obs.sourceFoundInSceneEnabled", {sourceName, sceneName, itemId: sceneItemId});
} catch (e) {
const isNotFound = e.message.toLowerCase().includes("not found") || (e.message.toLowerCase().includes("code") && e.message.toLowerCase().includes("600"));
if (isNotFound) {
logMessage(`Source '${sourceName}' not in scene '${sceneName}'. Adding it.`, "logMessages.obs.sourceNotInSceneAdding", {sourceName, sceneName});
try {
const createItemResponse = await sendRequest("CreateSceneItem", { sceneName, sourceName, sceneItemEnabled: true });
sceneItemId = createItemResponse.sceneItemId;
logMessage(`Source '${sourceName}' added to scene '${sceneName}', item ID: ${sceneItemId}.`, "logMessages.obs.sourceAddedToScene", {sourceName, sceneName, itemId: sceneItemId});
} catch (createError) {
logMessage(`Error creating scene item for '${sourceName}' in '${sceneName}': ${createError.message}`, "logMessages.obs.errorCreatingSceneItem", {sourceName, sceneName, message: createError.message});
return null;
}
} else {
logMessage(`Error checking for '${sourceName}' in scene '${sceneName}': ${e.message}`, "logMessages.obs.errorCheckingSceneForItem", {sourceName, sceneName, message: e.message});
return null;
}
}
if (sceneItemId) {
try {
const sizing = isScreenShare ? "bestFit" : sourceSizingSelect.value;
const defaultTransform = calculateTransform(sizing, canvasWidth, canvasHeight, canvasWidth, canvasHeight);
await sendRequest("SetSceneItemTransform", { sceneName, sceneItemId, sceneItemTransform: defaultTransform });
} catch (transformError) {
logMessage(`Error applying initial default transform to '${sourceName}' in '${sceneName}': ${transformError.message}`, "logMessages.obs.errorApplyingInitialTransform", {sourceName, sceneName, message: transformError.message});
}
}
return sceneItemId;
}
// Apply transform and grid (now triggers full layout update)
async function applyTransformAndGrid(
sceneName,
sourceName,
canvasWidth,
canvasHeight,
sceneItemId = null
) {
logMessage(`applyTransformAndGrid called for ${sourceName} in ${sceneName}. Triggering full layout update for scene.`, "logMessages.obs.applyTransformAndGridCalled", {sourceName, sceneName});
await triggerLayoutUpdateForScene(sceneName);
}
// Trigger layout update for a scene
async function triggerLayoutUpdateForScene(sceneName) {
if (!obsConnected || !obs || !sceneName) {
return;
}
logMessage(`Triggering layout update for scene '${sceneName}'...`, "logMessages.obs.triggeringLayoutUpdate", {sceneName});
const layoutConfig = getLayoutConfigForScene(sceneName);
try {
let canvasWidth = 1920, canvasHeight = 1080;
const videoSettings = await sendRequest("GetVideoSettings");
if (videoSettings && videoSettings.baseWidth && videoSettings.baseHeight) {
canvasWidth = videoSettings.baseWidth;
canvasHeight = videoSettings.baseHeight;
}
const sceneItemsResponse = await sendRequest("GetSceneItemList", { sceneName });
if (!sceneItemsResponse || !sceneItemsResponse.sceneItems) {
logMessage(`No scene items found in scene '${sceneName}' for layout update.`, "logMessages.obs.noSceneItemsForLayout", {sceneName});
return;
}
const camPrefix = getFullCameraPrefix();
const reactionMainPrefix = getFullReactionPrefix();
const highlightMainPrefix = getFullHighlightPrefix();
const vdoNinjaSceneItems = sceneItemsResponse.sceneItems.filter(item => {
const sName = item.sourceName;
const isGeneralCam = sName.startsWith(camPrefix);
const isReactionMain = reactionMainPrefix && sName.startsWith(reactionMainPrefix) && sName.endsWith(':s');
const isHighlightMain = highlightMainPrefix && sName.startsWith(highlightMainPrefix) && !sName.endsWith(':s');
if (isReactionMain || isHighlightMain) {
return true;
}
if (isGeneralCam) {
let streamId = null;
if (sName.startsWith(camPrefix + "_")) {
streamId = sName.substring((camPrefix + "_").length);
} else if (sName.startsWith(camPrefix + ".")) {
const parts = sName.split('.');
if (parts.length > 1) {
const potentialId = parts[parts.length -1];
if (activeStreams[potentialId] || activeStreams[potentialId.replace(':s','')]) return true;
}
} else {
streamId = sName.substring(camPrefix.length);
}
if (streamId && streamId.endsWith(':s')) streamId = streamId.slice(0, -2);
return streamId && activeStreams[streamId] && activeStreams[streamId].sourceCreated && activeStreams[streamId].connected;
}
return false;
});
if (layoutConfig) {
logMessage(`Applying '${layoutConfig.layoutType}' layout to scene '${sceneName}'. (${vdoNinjaSceneItems.length} items)`, "logMessages.obs.applyingConfiguredLayout", {layoutType: layoutConfig.layoutType, sceneName, count: vdoNinjaSceneItems.length});
switch (layoutConfig.layoutType) {
case 'grid':
await applyGridLayoutToScene(sceneName, vdoNinjaSceneItems, canvasWidth, canvasHeight, layoutConfig);
break;
case 'reaction':
await applyReactionLayoutToScene(sceneName, vdoNinjaSceneItems, canvasWidth, canvasHeight, layoutConfig);
break;
case 'highlight':
await applyHighlightLayoutToScene(sceneName, vdoNinjaSceneItems, canvasWidth, canvasHeight, layoutConfig);
break;
default:
logMessage(`Unknown layout type '${layoutConfig.layoutType}' for scene '${sceneName}'. Applying default sizing.`, "logMessages.obs.unknownLayoutType", {layoutType: layoutConfig.layoutType, sceneName});
await applyDefaultSizingToSceneItems(sceneName, vdoNinjaSceneItems, canvasWidth, canvasHeight);
}
} else {
logMessage(`No specific layout for scene '${sceneName}'. Applying default source sizing ('${sourceSizingSelect.value}').`, "logMessages.obs.noSpecificLayoutApplyingDefault", {sceneName, sizing: sourceSizingSelect.value});
await applyDefaultSizingToSceneItems(sceneName, vdoNinjaSceneItems, canvasWidth, canvasHeight);
}
} catch (error) {
let errorMessage = error.message || "Unknown error during layout update";
if (error.description) errorMessage = error.description;
logMessage(`Error during layout update for scene '${sceneName}': ${errorMessage}`, "logMessages.obs.errorTriggeringLayoutUpdate", {sceneName, message: errorMessage});
console.error("Full layout error object:", error);
}
}
// Apply default sizing to scene items
async function applyDefaultSizingToSceneItems(sceneName, sceneItems, canvasWidth, canvasHeight) {
logMessage(`Applying default source sizing ('${sourceSizingSelect.value}') to ${sceneItems.length} items in scene '${sceneName}'.`, "logMessages.obs.applyingDefaultSizing", {sizing: sourceSizingSelect.value, count: sceneItems.length, sceneName});
for (const item of sceneItems) {
try {
const inputSettingsResponse = await getInputSettingsSafe(item.sourceName, canvasWidth, canvasHeight);
let sourceW = inputSettingsResponse.width;
let sourceH = inputSettingsResponse.height;
const transform = calculateTransform(sourceSizingSelect.value, sourceW, sourceH, canvasWidth, canvasHeight);
await sendRequest("SetSceneItemTransform", { sceneName, sceneItemId: item.sceneItemId, sceneItemTransform: transform });
await sendRequest("SetSceneItemEnabled", { sceneName, sceneItemId: item.sceneItemId, sceneItemEnabled: true });
} catch (e) {
logMessage(`Error applying default transform to ${item.sourceName} in ${sceneName}: ${e.message}`, "logMessages.obs.errorApplyingDefaultTransformItem", {sourceName: item.sourceName, sceneName, message: e.message});
}
}
}
// Apply grid layout to scene
async function applyGridLayoutToScene(sceneName, vdoNinjaSceneItems, canvasWidth, canvasHeight, layoutConfig) {
const generalCamPrefix = getFullCameraPrefix();
const cameraItems = vdoNinjaSceneItems.filter(item => item.sourceName.startsWith(generalCamPrefix) && !item.sourceName.endsWith(':s'));
if (cameraItems.length === 0) {
logMessage(`No VDO.Ninja camera sources to apply Grid layout in scene '${sceneName}'.`, "logMessages.obs.noSourcesForGridLayout", {sceneName});
for (const item of vdoNinjaSceneItems) {
if (!cameraItems.find(ci => ci.sceneItemId === item.sceneItemId)) {
await hideSourceItem(sceneName, item.sceneItemId, item.sourceName);
}
}
return;
}
logMessage(`Applying Grid Layout to ${cameraItems.length} VDO.Ninja camera sources in scene '${sceneName}'.`, "logMessages.obs.applyingGridLayoutScene", {count: cameraItems.length, sceneName});
const margin = layoutConfig.margin ?? DEFAULT_GRID_MARGIN;
const spacing = layoutConfig.spacing ?? DEFAULT_GRID_SPACING;
const offsetX = layoutConfig.offsetX ?? DEFAULT_GRID_X_OFFSET;
const offsetY = layoutConfig.offsetY ?? DEFAULT_GRID_Y_OFFSET;
const splitScreenTwoCameras = layoutConfig.splitScreenTwoCameras ?? DEFAULT_GRID_SPLIT_SCREEN;
if (splitScreenTwoCameras && cameraItems.length === 2) {
logMessage(`Applying 2-camera split screen grid layout to scene '${sceneName}'.`, "logMessages.obs.applyingGridSplitScreen", {sceneName});
const itemWidth = canvasWidth / 2;
const itemHeight = canvasHeight;
for (let i = 0; i < cameraItems.length; i++) {
const item = cameraItems[i];
const camSrcInfo = await getInputSettingsSafe(item.sourceName, 1920, 1080);
const sourceAspectRatio = camSrcInfo.width / camSrcInfo.height;
const targetAspectRatio = itemWidth / itemHeight;
let cropLeft = 0, cropRight = 0, cropTop = 0, cropBottom = 0;
let scaledWidth = camSrcInfo.width;
let scaledHeight = camSrcInfo.height;
if (sourceAspectRatio > targetAspectRatio) {
scaledHeight = itemHeight;
scaledWidth = scaledHeight * sourceAspectRatio;
const excessWidth = scaledWidth - itemWidth;
cropLeft = Math.round( (excessWidth / 2) / scaledWidth * camSrcInfo.width );
cropRight = cropLeft;
} else {
scaledWidth = itemWidth;
scaledHeight = scaledWidth / sourceAspectRatio;
const excessHeight = scaledHeight - itemHeight;
cropTop = Math.round( (excessHeight / 2) / scaledHeight * camSrcInfo.height);
cropBottom = cropTop;
}
const finalScaleX = itemWidth / (camSrcInfo.width - cropLeft - cropRight);
const finalScaleY = itemHeight / (camSrcInfo.height - cropTop - cropBottom);
const transform = {
alignment: 5, boundsType: "OBS_BOUNDS_NONE",
positionX: i * itemWidth + offsetX,
positionY: offsetY,
scaleX: finalScaleX,
scaleY: finalScaleY,
width: itemWidth, height: itemHeight,
sourceWidth: camSrcInfo.width, sourceHeight: camSrcInfo.height,
cropLeft: cropLeft, cropRight: cropRight,
cropTop: cropTop, cropBottom: cropBottom,
};
await sendRequest("SetSceneItemEnabled", {sceneName, sceneItemId: item.sceneItemId, sceneItemEnabled: true});
await sendRequest("SetSceneItemTransform", { sceneName, sceneItemId: item.sceneItemId, sceneItemTransform: transform });
}
} else {
const positions = calculateGridPositions(
cameraItems.length, canvasWidth, canvasHeight,
margin, spacing, CELL_ASPECT_RATIO, offsetX, offsetY
);
for (let i = 0; i < cameraItems.length; i++) {
const item = cameraItems[i];
const gridPos = positions[i];
if (gridPos) {
const camSrcInfo = await getInputSettingsSafe(item.sourceName, 1920, 1080);
const transform = calculateTransform(
"autoGrid", camSrcInfo.width, camSrcInfo.height,
canvasWidth, canvasHeight, gridPos
);
await sendRequest("SetSceneItemEnabled", {sceneName, sceneItemId: item.sceneItemId, sceneItemEnabled: true});
await sendRequest("SetSceneItemTransform", {
sceneName, sceneItemId: item.sceneItemId, sceneItemTransform: transform,
});
}
}
}
const cameraItemIds = new Set(cameraItems.map(it => it.sceneItemId));
for (const item of vdoNinjaSceneItems) {
if (!cameraItemIds.has(item.sceneItemId)) {
await hideSourceItem(sceneName, item.sceneItemId, item.sourceName);
}
}
}
// Apply reaction or highlight layout (shared logic)
async function applyReactionOrHighlightLayoutShared(sceneName, vdoNinjaSceneItems, canvasWidth, canvasHeight, layoutConfig, isReactionLayout) {
const generalCamPrefix = getFullCameraPrefix();
const mainContentPrefix = isReactionLayout ? getFullReactionPrefix() : getFullHighlightPrefix();
const mainContentSuffix = isReactionLayout ? ':s' : '';
const mainContentSources = vdoNinjaSceneItems.filter(item =>
item.sourceName.startsWith(mainContentPrefix) &&
(isReactionLayout ? item.sourceName.endsWith(mainContentSuffix) : !item.sourceName.endsWith(':s'))
);
const sideCameraSources = vdoNinjaSceneItems.filter(item =>
item.sourceName.startsWith(generalCamPrefix) &&
!mainContentSources.find(mcs => mcs.sourceName === item.sourceName) &&
!item.sourceName.endsWith(':s')
);
let mainContentItem = null;
if (mainContentSources.length > 0) {
try {
if (isReactionLayout && screenShareId) {
const currentScreenShareSourceName = `${mainContentPrefix}_${screenShareId}${mainContentSuffix}`;
const activeScreenShareItem = mainContentSources.find(mcs => mcs.sourceName === currentScreenShareSourceName);
if (activeScreenShareItem) {
const itemDetails = await sendRequest("GetSceneItemEnabled", { sceneName, sceneItemId: activeScreenShareItem.sceneItemId });
if (itemDetails.sceneItemEnabled) mainContentItem = activeScreenShareItem;
}
}
if (!mainContentItem && mainContentSources[0]) {
// Fallback to the first mainContentSource if specific (screenShare) isn't active/visible
// This handles the case where a highlight source might be present but not the *active* one (e.g. if multiple highlight sources exist)
const itemDetails = await sendRequest("GetSceneItemEnabled", { sceneName, sceneItemId: mainContentSources[0].sceneItemId });
if (itemDetails.sceneItemEnabled) {
mainContentItem = mainContentSources[0];
}
}
} catch (e) {
logMessage(`Error checking if main content is enabled: ${e.message}`, "logMessages.obs.errorCheckingMainContentEnabled", {sourceName: mainContentSources[0]?.sourceName || 'unknown', message: e.message});
}
}
if (!mainContentItem) {
logMessage(`Main content for ${isReactionLayout ? 'Reaction' : 'Highlight'} layout in scene '${sceneName}' not found/visible. Applying fallback grid of cameras.`,
isReactionLayout ? "logMessages.obs.reactionMainNotFoundFallbackGrid" : "logMessages.obs.highlightMainNotFoundFallbackGrid", { sceneName });
let fallbackGridMargin = DEFAULT_GRID_MARGIN;
let fallbackGridSpacing = DEFAULT_GRID_SPACING;
let fallbackGridOffsetX = DEFAULT_GRID_X_OFFSET;
let fallbackGridOffsetY = DEFAULT_GRID_Y_OFFSET;
let fallbackGridSplitScreenTwoCameras = DEFAULT_GRID_SPLIT_SCREEN;
const firstGridSceneConfig = sceneLayoutConfigs.find(cfg => cfg.layoutType === 'grid' && cfg.sceneName);
if (firstGridSceneConfig) {
logMessage(`Using grid settings from the first configured 'Grid' layout scene ('${firstGridSceneConfig.sceneName}') as fallback for '${sceneName}'.`, "logMessages.obs.usingGridSceneFallbackSettings", { sceneName: firstGridSceneConfig.sceneName, targetScene: sceneName });
fallbackGridMargin = firstGridSceneConfig.margin ?? DEFAULT_GRID_MARGIN;
fallbackGridSpacing = firstGridSceneConfig.spacing ?? DEFAULT_GRID_SPACING;
fallbackGridOffsetX = firstGridSceneConfig.offsetX ?? DEFAULT_GRID_X_OFFSET;
fallbackGridOffsetY = firstGridSceneConfig.offsetY ?? DEFAULT_GRID_Y_OFFSET;
fallbackGridSplitScreenTwoCameras = firstGridSceneConfig.splitScreenTwoCameras ?? DEFAULT_GRID_SPLIT_SCREEN;
} else {
logMessage(`No 'Grid' layout scene found. Using default grid settings as fallback for '${sceneName}'.`, "logMessages.obs.usingDefaultGridFallbackSettings", {targetScene: sceneName});
}
const fallbackGridConfig = {
margin: fallbackGridMargin,
spacing: fallbackGridSpacing,
offsetX: fallbackGridOffsetX,
offsetY: fallbackGridOffsetY,
splitScreenTwoCameras: fallbackGridSplitScreenTwoCameras,
};
const cameraItemsForFallbackGrid = vdoNinjaSceneItems.filter(item =>
item.sourceName.startsWith(generalCamPrefix) && !item.sourceName.endsWith(':s')
);
if (cameraItemsForFallbackGrid.length > 0) {
logMessage(`Applying fallback Grid layout to ${cameraItemsForFallbackGrid.length} camera sources in scene '${sceneName}'.`, "logMessages.obs.applyingFallbackGridToCameras", { count: cameraItemsForFallbackGrid.length, sceneName });
await applyGridLayoutToScene(sceneName, cameraItemsForFallbackGrid, canvasWidth, canvasHeight, fallbackGridConfig);
} else {
logMessage(`No camera items to display in fallback grid for scene '${sceneName}'. All VDO sources in this scene will be hidden.`, "logMessages.obs.noCamerasForFallbackGridHidingAll", {sceneName});
}
const cameraItemIdsForFallback = new Set(cameraItemsForFallbackGrid.map(it => it.sceneItemId));
for (const item of vdoNinjaSceneItems) {
if (!cameraItemIdsForFallback.has(item.sceneItemId)) {
await hideSourceItem(sceneName, item.sceneItemId, item.sourceName);
}
}
return;
}
const mainSourceItem = mainContentItem;
const spacing = layoutConfig.spacing ?? DEFAULT_REACTION_HIGHLIGHT_SPACING;
const offsetX = layoutConfig.offsetX ?? DEFAULT_REACTION_HIGHLIGHT_X_OFFSET;
const offsetY = layoutConfig.offsetY ?? DEFAULT_REACTION_HIGHLIGHT_Y_OFFSET;
const distributeCameras = layoutConfig.distributeCameras ?? DEFAULT_REACTION_HIGHLIGHT_DISTRIBUTE_CAMERAS;
const cameraCropRatio = isReactionLayout ? REACTION_CAMERA_CROP_RATIO : HIGHLIGHT_CAMERA_CROP_RATIO;
const itemAspectRatio = REACTION_HIGHLIGHT_ASPECT_RATIO; // Aspect ratio for the main content
let camsLeft = [], camsRight = [];
if (distributeCameras && sideCameraSources.length > 0) {
const half = Math.ceil(sideCameraSources.length / 2);
camsLeft = sideCameraSources.slice(0, half);
camsRight = sideCameraSources.slice(half);
} else {
camsLeft = [...sideCameraSources]; // All cameras on the left if not distributing or only one side needed
}
// Determine width for camera columns (fixed percentage for now, could be configurable)
let leftCameraAreaWidth = camsLeft.length > 0 ? canvasWidth * 0.15 : 0;
let rightCameraAreaWidth = camsRight.length > 0 ? canvasWidth * 0.15 : 0;
// MODIFICATION STARTS HERE: Calculate main content width based on remaining space
let baseScaleWidthForMainContent = canvasWidth;
baseScaleWidthForMainContent -= spacing;
baseScaleWidthForMainContent -= spacing;
if (leftCameraAreaWidth > 0) {
baseScaleWidthForMainContent -= leftCameraAreaWidth;
baseScaleWidthForMainContent -= spacing; // Subtract spacing between left column and main content
}
if (rightCameraAreaWidth > 0) {
baseScaleWidthForMainContent -= rightCameraAreaWidth;
baseScaleWidthForMainContent -= spacing; // Subtract spacing between main content and right column
}
let mainContentVisualWidth = Math.max(1, baseScaleWidthForMainContent);
let mainContentVisualHeight = mainContentVisualWidth / itemAspectRatio;
// Adjust if height exceeds available canvas height (considering top/bottom edge spacing)
const availableHeightForScaling = canvasHeight - (2 * spacing);
if (mainContentVisualHeight > availableHeightForScaling) {
mainContentVisualHeight = Math.max(1, availableHeightForScaling);
mainContentVisualWidth = mainContentVisualHeight * itemAspectRatio;
}
// MODIFICATION ENDS HERE for scaling calculation
// Calculate X position for the main content, including offsetX
const mainContentVisualX = spacing +
(leftCameraAreaWidth > 0 ? (leftCameraAreaWidth + spacing) : 0) +
offsetX;
// Calculate Y position for the main content, centered and including offsetY
const mainContentVisualY = (canvasHeight - mainContentVisualHeight) / 2 + offsetY;
if (mainSourceItem) {
const mainSrcInfo = await getInputSettingsSafe(mainSourceItem.sourceName, canvasWidth, canvasHeight);
const transformMain = {
alignment: 5, boundsType: "OBS_BOUNDS_NONE", // Using NONE and explicit scale
positionX: mainContentVisualX,
positionY: mainContentVisualY,
scaleX: mainSrcInfo.width === 0 ? 0 : mainContentVisualWidth / mainSrcInfo.width,
scaleY: mainSrcInfo.height === 0 ? 0 : mainContentVisualHeight / mainSrcInfo.height,
width: mainContentVisualWidth, // Visual width on canvas
height: mainContentVisualHeight, // Visual height on canvas
sourceWidth: mainSrcInfo.width,
sourceHeight: mainSrcInfo.height,
cropLeft: 0, cropRight: 0, cropTop: 0, cropBottom: 0,
};
await sendRequest("SetSceneItemEnabled", {sceneName, sceneItemId: mainSourceItem.sceneItemId, sceneItemEnabled: true});
await sendRequest("SetSceneItemTransform", { sceneName, sceneItemId: mainSourceItem.sceneItemId, sceneItemTransform: transformMain });
}
const positionCameras = async (cameraList, startXForColumn, columnTotalWidth) => {
if (cameraList.length > 0 && columnTotalWidth > 0) {
const camAvailableHeightTotal = canvasHeight - (cameraList.length + 1) * spacing;
let camCellHeight = (cameraList.length > 0) ? Math.max(1, camAvailableHeightTotal / cameraList.length) : 0;
// Calculate width based on height and aspect ratio, then apply crop
let camCellOriginalWidthUncropped = camCellHeight * itemAspectRatio; // Camera's original aspect ratio assumed same as main for now
let camCellVisibleWidthAfterCrop = camCellOriginalWidthUncropped * cameraCropRatio;
// If visible width after crop is too wide for the column, or invalid, adjust.
// Prioritize fitting into columnTotalWidth.
if (camCellVisibleWidthAfterCrop > columnTotalWidth || camCellVisibleWidthAfterCrop <= 0) {
camCellVisibleWidthAfterCrop = Math.max(1, columnTotalWidth);
if (cameraCropRatio > 0) { // Recalculate original width and height based on fitting visible width
camCellOriginalWidthUncropped = camCellVisibleWidthAfterCrop / cameraCropRatio;
camCellHeight = camCellOriginalWidthUncropped / itemAspectRatio;
} else { // No crop, so original width is visible width
camCellOriginalWidthUncropped = camCellVisibleWidthAfterCrop;
camCellHeight = camCellOriginalWidthUncropped / itemAspectRatio;
}
}
const actualCamerasBlockHeight = cameraList.length * camCellHeight + Math.max(0, cameraList.length - 1) * spacing;
let currentCamY = (canvasHeight - actualCamerasBlockHeight) / 2 + offsetY;
for (const camItem of cameraList) {
const camSrcInfo = await getInputSettingsSafe(camItem.sourceName, 1920, 1080);
let cropLeftPixels = 0;
let cropRightPixels = 0;
let effectiveCamWidthForScale = camSrcInfo.width;
if (cameraCropRatio < 1.0 && cameraCropRatio > 0) { // Only apply crop if ratio is valid and less than 1
const totalHorizontalCrop = camSrcInfo.width * (1 - cameraCropRatio);
cropLeftPixels = Math.round(totalHorizontalCrop / 2);
cropRightPixels = Math.round(totalHorizontalCrop / 2);
effectiveCamWidthForScale = camSrcInfo.width - cropLeftPixels - cropRightPixels;
}
const transformCam = {
alignment: 5, boundsType: "OBS_BOUNDS_NONE",
positionX: startXForColumn + offsetX, // Apply offsetX to camera columns too
positionY: currentCamY, // offsetY is already in currentCamY
scaleX: effectiveCamWidthForScale <= 0 ? 0 : camCellVisibleWidthAfterCrop / effectiveCamWidthForScale,
scaleY: camSrcInfo.height <= 0 ? 0 : camCellHeight / camSrcInfo.height,
width: camCellVisibleWidthAfterCrop, // Visual width on canvas
height: camCellHeight, // Visual height on canvas
sourceWidth: camSrcInfo.width,
sourceHeight: camSrcInfo.height,
cropLeft: cropLeftPixels,
cropRight: cropRightPixels,
cropTop: 0, cropBottom: 0,
};
await sendRequest("SetSceneItemEnabled", {sceneName, sceneItemId: camItem.sceneItemId, sceneItemEnabled: true});
await sendRequest("SetSceneItemTransform", { sceneName, sceneItemId: camItem.sceneItemId, sceneItemTransform: transformCam });
currentCamY += camCellHeight + spacing;
}
}
};
// Position left cameras
const startXLeftColumn = spacing; // Relative to canvas edge before offsetX
await positionCameras(camsLeft, startXLeftColumn, leftCameraAreaWidth);
// Position right cameras (if any)
if (distributeCameras && camsRight.length > 0) {
// Start X for right column is after main content and its preceding space
const startXRightColumn = spacing + /*left edge*/
(leftCameraAreaWidth > 0 ? (leftCameraAreaWidth + spacing) : 0) + /*left col area*/
mainContentVisualWidth + spacing; /*main content and space after it*/
await positionCameras(camsRight, startXRightColumn, rightCameraAreaWidth);
}
const layoutItemIds = new Set();
if(mainSourceItem) layoutItemIds.add(mainSourceItem.sceneItemId);
sideCameraSources.forEach(cam => layoutItemIds.add(cam.sceneItemId));
for (const item of vdoNinjaSceneItems) {
if (!layoutItemIds.has(item.sceneItemId)) {
await hideSourceItem(sceneName, item.sceneItemId, item.sourceName);
}
}
}
// Apply reaction layout to scene
async function applyReactionLayoutToScene(sceneName, vdoNinjaSceneItems, canvasWidth, canvasHeight, layoutConfig) {
await applyReactionOrHighlightLayoutShared(sceneName, vdoNinjaSceneItems, canvasWidth, canvasHeight, layoutConfig, true);
}
// Apply highlight layout to scene
async function applyHighlightLayoutToScene(sceneName, vdoNinjaSceneItems, canvasWidth, canvasHeight, layoutConfig) {
await applyReactionOrHighlightLayoutShared(sceneName, vdoNinjaSceneItems, canvasWidth, canvasHeight, layoutConfig, false);
}
// Calculate grid positions
function calculateGridPositions(
totalSources, canvasWidth, canvasHeight, margin, spacing, cellAspectRatio, xOffset, yOffset
) {
const positions = [];
if (totalSources === 0) return positions;
let cols = Math.ceil(Math.sqrt(totalSources));
let rows = Math.ceil(totalSources / cols);
if (cols === 0) cols = 1;
if (rows === 0) rows = 1;
const availableWidthAfterMargin = canvasWidth - 2 * margin;
const availableHeightAfterMargin = canvasHeight - 2 * margin;
if (availableWidthAfterMargin <= 0 || availableHeightAfterMargin <= 0) return positions;
let cellWidthInitial = (availableWidthAfterMargin - (cols - 1) * spacing) / cols;
let cellHeightInitial = (availableHeightAfterMargin - (rows - 1) * spacing) / rows;
if (cellWidthInitial <= 0 || cellHeightInitial <= 0) return positions;
let finalCellWidth = cellWidthInitial;
let finalCellHeight = cellHeightInitial;
if (finalCellWidth / cellAspectRatio <= finalCellHeight) {
finalCellHeight = finalCellWidth / cellAspectRatio;
} else {
finalCellWidth = finalCellHeight * cellAspectRatio;
}
if (finalCellWidth <= 0 || finalCellHeight <= 0) return positions;
const totalBlockWidth = cols * finalCellWidth + Math.max(0, cols - 1) * spacing;
const totalBlockHeight = rows * finalCellHeight + Math.max(0, rows - 1) * spacing;
const verticalPaddingForBlock = (availableHeightAfterMargin - totalBlockHeight) / 2;
for (let i = 0; i < totalSources; i++) {
const rowIdx = Math.floor(i / cols);
const colIdx = i % cols;
const itemsInCurrentRow = (rowIdx === rows - 1) ? (totalSources - (rowIdx * cols)) : cols;
const widthOfCurrentRow = itemsInCurrentRow * finalCellWidth + Math.max(0, itemsInCurrentRow - 1) * spacing;
const horizontalPaddingForRow = (availableWidthAfterMargin - widthOfCurrentRow) / 2;
const currentX = margin + horizontalPaddingForRow + colIdx * (finalCellWidth + spacing) + xOffset;
const currentY = margin + verticalPaddingForBlock + rowIdx * (finalCellHeight + spacing) + yOffset;
positions.push({
x: currentX,
y: currentY,
width: finalCellWidth,
height: finalCellHeight,
});
}
return positions;
}
// Calculate transform for a scene item
function calculateTransform(
sizingMode, sourceWidth, sourceHeight, canvasWidth, canvasHeight, gridPosition = null
) {
let transform = {
alignment: 5, boundsType: "OBS_BOUNDS_NONE", boundsAlignment: 0,
boundsWidth: sourceWidth, boundsHeight: sourceHeight,
positionX: (canvasWidth - sourceWidth) / 2, positionY: (canvasHeight - sourceHeight) / 2,
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;
transform.positionX = 0; transform.positionY = 0;
break;
case "bestFit":
transform.boundsType = "OBS_BOUNDS_SCALE_INNER";
transform.boundsWidth = canvasWidth; transform.boundsHeight = canvasHeight;
transform.width = canvasWidth; transform.height = canvasHeight;
transform.positionX = 0; transform.positionY = 0;
break;
case "autoGrid":
if (gridPosition && gridPosition.width > 0 && gridPosition.height > 0) {
transform.boundsType = "OBS_BOUNDS_NONE";
transform.positionX = gridPosition.x;
transform.positionY = gridPosition.y;
transform.scaleX = (transform.sourceWidth === 0) ? 0 : gridPosition.width / transform.sourceWidth;
transform.scaleY = (transform.sourceHeight === 0) ? 0 : gridPosition.height / transform.sourceHeight;
transform.width = transform.sourceWidth * transform.scaleX;
transform.height = transform.sourceHeight * transform.scaleY;
transform.cropLeft = 0; transform.cropRight = 0; transform.cropTop = 0; transform.cropBottom = 0;
transform.boundsWidth = gridPosition.width; transform.boundsHeight = gridPosition.height;
} else {
transform.boundsType = "OBS_BOUNDS_SCALE_INNER";
transform.boundsWidth = canvasWidth; transform.boundsHeight = canvasHeight;
transform.width = canvasWidth; transform.height = canvasHeight;
transform.positionX = 0; transform.positionY = 0;
}
break;
case "defaultSize":
default:
transform.positionX = 0; transform.positionY = 0;
if (transform.sourceWidth > canvasWidth || transform.sourceHeight > canvasHeight) {
const scaleRatioX = canvasWidth / transform.sourceWidth;
const scaleRatioY = canvasHeight / transform.sourceHeight;
const scale = Math.min(scaleRatioX, scaleRatioY);
transform.scaleX = scale; transform.scaleY = scale;
transform.width = transform.sourceWidth * scale; transform.height = transform.sourceHeight * scale;
transform.positionX = (canvasWidth - transform.width) / 2;
transform.positionY = (canvasHeight - transform.height) / 2;
} else {
transform.scaleX = 1.0; transform.scaleY = 1.0;
transform.width = transform.sourceWidth; transform.height = transform.sourceHeight;
}
break;
}
return transform;
}
// Remove stream from OBS
async function removeStreamFromObs(streamId) {
if (!obsConnected || !obs) {
logMessage(
`Cannot remove stream ${streamId} from OBS: Not connected to OBS.`,
"logMessages.obs.cannotRemoveStreamNotConnected", { id: streamId }
);
return;
}
const baseSourceName = `${getFullCameraPrefix()}_${streamId}`;
const highlightedSourceName = `${getFullHighlightPrefix()}_${streamId}`;
logMessage(
`User or auto-triggered removal of stream '${streamId}' from OBS. Base source: '${baseSourceName}'.`,
"logMessages.obs.triggeredRemoval", { id: streamId, baseName: baseSourceName }
);
const streamInfo = activeStreams[streamId];
const currentLabel = streamInfo ? streamInfo.label : (window.translation ? getTranslation("vdoNinja.defaultStreamLabel", {id: streamId}) : `Stream ${streamId}`);
const targetInfo = getTargetSceneForStream(streamId, currentLabel);
if (highlightedStreamId === streamId) {
logMessage(`Stream ${streamId} was legacy highlighted. Unhighlighting.`, "logMessages.obs.legacyHighlightUnhighlightOnRemove", {id: streamId});
await toggleHighlight(streamId, currentLabel, targetInfo);
}
const scenesToClean = new Set();
document.querySelectorAll('#sourceCreationScenesContainer input[type="checkbox"]:checked').forEach(cb => {
if (cb.value) scenesToClean.add(cb.value);
});
if (targetInfo && targetInfo.scene) scenesToClean.add(targetInfo.scene);
let itemRemovedThisRun = false;
for (const sceneName of scenesToClean) {
if (!sceneName) continue;
if (await tryRemoveFromScene(baseSourceName, sceneName)) {
itemRemovedThisRun = true;
}
if (highlightedSourceName !== baseSourceName) {
if (await tryRemoveFromScene(highlightedSourceName, sceneName)) itemRemovedThisRun = true;
}
}
if (screenShareId === streamId) {
logMessage(`Stream ${streamId} was actively screen sharing. Initiating screen share removal.`, "logMessages.obs.streamScreenSharingRemoving", { id: streamId });
await removeScreenShareFromObs(streamId);
}
if (activeStreams[streamId]) activeStreams[streamId].sourceCreated = false;
logMessage(`Finished removing source items for stream ${streamId} from specified OBS scenes.`, "logMessages.obs.finishedRemovingSourceItems", { id: streamId });
scenesToClean.forEach(scene => {
if (scene) triggerLayoutUpdateForScene(scene);
});
if (screenShareId !== streamId) updateStreamList();
saveSettings();
}
// Remove screen share from OBS
async function removeScreenShareFromObs(streamIdToRemove) {
if (!obsConnected || !obs) {
logMessage("Cannot remove screen share: Not connected to OBS.", "logMessages.obs.cannotRemoveScreenShareNotConnected");
return;
}
if (!streamIdToRemove) {
if (screenShareId === streamIdToRemove) {
screenShareId = null;
updateStreamList();
}
return;
}
const sourceName = `${getFullReactionPrefix()}_${streamIdToRemove}:s`;
logMessage(
`Attempting to fully remove screen share source '${sourceName}' from OBS (all scenes and input).`,
"logMessages.obs.attemptingRemoveScreenShareSource", { sourceName }
);
let itemRemovedFromAnyScene = false;
const scenesWithItem = new Set();
try {
const reactionLayoutScenes = sceneLayoutConfigs
.filter(cfg => cfg.layoutType === 'reaction' && cfg.sceneName)
.map(cfg => cfg.sceneName);
for (const sceneName of reactionLayoutScenes) {
if (await tryRemoveFromScene(sourceName, sceneName)) {
itemRemovedFromAnyScene = true;
scenesWithItem.add(sceneName);
}
}
const scenesResponse = await sendRequest("GetSceneList");
if (scenesResponse && scenesResponse.scenes) {
for (const scene of scenesResponse.scenes) {
if (!scenesWithItem.has(scene.sceneName)) {
if (await tryRemoveFromScene(sourceName, scene.sceneName)) {
itemRemovedFromAnyScene = true;
scenesWithItem.add(scene.sceneName);
}
}
}
}
try {
await sendRequest("GetInputSettings", { inputName: sourceName }, { suppressNotFound: true });
logMessage(`Removing global input '${sourceName}' from OBS.`, "logMessages.obs.removingGlobalInput", { sourceName });
await sendRequest("RemoveInput", { inputName: sourceName });
logMessage(`Successfully removed input '${sourceName}'.`, "logMessages.obs.successfullyRemovedInput", { sourceName });
} catch (e) { /* Suppress if input not found, already removed */ }
} catch (error) {
logMessage(
`Error during screen share removal process for '${sourceName}': ${error.message}`,
"logMessages.obs.errorScreenShareRemovalProcess", { sourceName, message: error.message }
);
} finally {
if (screenShareId === streamIdToRemove) screenShareId = null;
updateStreamList();
scenesWithItem.forEach(sceneName => triggerLayoutUpdateForScene(sceneName));
}
}
// Try to remove source from scene
async function tryRemoveFromScene(sourceName, sceneName) {
if (!sceneName) return false;
try {
const itemInfo = await sendRequest("GetSceneItemId", { sceneName, sourceName }, { suppressNotFound: true });
if (itemInfo && itemInfo.sceneItemId) {
logMessage(`Removing source item '${sourceName}' (ID: ${itemInfo.sceneItemId}) from scene '${sceneName}'.`, "logMessages.obs.removingSourceItemFromScene", { sourceName, itemId: itemInfo.sceneItemId, sceneName });
await sendRequest("RemoveSceneItem", { sceneName, sceneItemId: itemInfo.sceneItemId });
return true;
}
return false;
} catch (error) {
const isNotFound = error.message.toLowerCase().includes("not found") || error.message.toLowerCase().includes("no scene items were found") || error.message.toLowerCase().includes("could not find") || (error.message.toLowerCase().includes("code") && error.message.toLowerCase().includes("600"));
if (!isNotFound) {
logMessage(`Error trying to remove source item '${sourceName}' from scene '${sceneName}': ${error.message}`, "logMessages.obs.errorTryingRemoveSourceItem", { sourceName, sceneName, message: error.message });
}
return false;
}
}
// Load jsSHA library (fallback)
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 = () => {
logMessage("jsSHA library loaded successfully (fallback for Web Crypto).", "logMessages.jsShaLoaded");
resolve();
};
script.onerror = () => {
logMessage("Error: Failed to load jsSHA library. OBS authentication might fail if Web Crypto is also unavailable.", "logMessages.errorLoadingJsSha");
reject(new Error("Failed to load jsSHA library"));
};
document.head.appendChild(script);
});
}
// Setup blur for secure fields
function setupSecureFieldsBlur() {
const secureFields = document.querySelectorAll(
"#vdoNinjaRoom, #vdoNinjaStreamIds"
);
secureFields.forEach((field) => {
field.classList.add("blur-field");
field.addEventListener("focus", () =>
field.classList.remove("blur-field")
);
field.addEventListener("blur", () => {
if (field.value === "") field.classList.add("blur-field");
});
if (field.value === "") {
field.classList.add("blur-field");
}
});
}
loadSourceCreationScenesBtn.addEventListener("click", fetchObsScenes);
[
obsWsUrlInput, obsWsPasswordInput,
vdoNinjaBaseUrlInput, vdoNinjaRoomInput, vdoNinjaPasswordInput, vdoNinjaStreamIdsInput,
autoAddSourcesCheckbox, autoRemoveSourcesCheckbox,
document.getElementById("screenShareWidth"), document.getElementById("screenShareHeight"),
sourceSizingSelect,
sourceCodecSelect
].forEach((el) => {
if (el) el.addEventListener("change", saveSettings);
});
sourceSizingSelect.addEventListener("change", () => {
saveSettings();
const defaultScene = getTargetScene();
if (defaultScene && !getLayoutConfigForScene(defaultScene)) {
triggerLayoutUpdateForScene(defaultScene);
}
obsScenes.forEach(scene => {
if (!getLayoutConfigForScene(scene.sceneName)) {
triggerLayoutUpdateForScene(scene.sceneName);
}
});
});
sourceCodecSelect.addEventListener("change", async () => {
saveSettings();
const newCodec = sourceCodecSelect.value;
logMessage(
`Codec changed to: ${newCodec || "none"}. Updating OBS sources...`,
"logMessages.obs.codecChangedUpdatingSources", { codec: newCodec || "none" }
);
if (!obsConnected) {
logMessage("OBS is not connected. Sources will not be updated with the new codec until reconnection and a new action.", "logMessages.obs.codecChangedNotConnected");
return;
}
for (const streamId in activeStreams) {
if (Object.hasOwnProperty.call(activeStreams, streamId) && activeStreams[streamId].sourceCreated) {
let effectiveSourceName = `${getFullCameraPrefix()}_${streamId}`;
if (highlightedStreamId === streamId && getFullHighlightPrefix() !== getFullCameraPrefix()) {
effectiveSourceName = `${getFullHighlightPrefix()}_${streamId}`;
}
try {
const currentSettingsResponse = await sendRequest("GetInputSettings", { inputName: effectiveSourceName }, { suppressNotFound: true });
if (currentSettingsResponse && currentSettingsResponse.inputSettings) {
const newViewUrl = getVdoNinjaViewUrl(streamId);
const updatedSettings = {
...currentSettingsResponse.inputSettings,
url: newViewUrl,
css: customCssInput.value.trim(),
};
await sendRequest("SetInputSettings", { inputName: effectiveSourceName, inputSettings: updatedSettings });
logMessage(`Source '${effectiveSourceName}' updated with URL: ${newViewUrl} and custom CSS.`, "logMessages.obs.sourceUpdatedWithUrlAndCss", { sourceName: effectiveSourceName, url: newViewUrl });
}
} catch (error) { /* Suppress if source not found or other error during update */ }
}
}
if (screenShareId) {
const screenShareSourceName = `${getFullReactionPrefix()}_${screenShareId}:s`;
try {
const currentScreenShareSettingsResponse = await sendRequest("GetInputSettings", { inputName: screenShareSourceName }, { suppressNotFound: true });
if (currentScreenShareSettingsResponse && currentScreenShareSettingsResponse.inputSettings) {
const room = vdoNinjaRoomInput.value.trim();
const baseUrl = getVdoNinjaBaseUrl();
let newScreenShareUrl = `${baseUrl}/?view=${encodeURIComponent(screenShareId)}:s&solo&room=${encodeURIComponent(room)}`;
if (newCodec && newCodec !== "") newScreenShareUrl += `&codec=${encodeURIComponent(newCodec)}`; else newScreenShareUrl += `&codec=vp9`;
if (vdoNinjaPasswordInput.value) newScreenShareUrl += `&password=${encodeURIComponent(vdoNinjaPasswordInput.value)}`;
newScreenShareUrl += "&cleanoutput&transparent&proaudio";
const updatedScreenShareSettings = { ...currentScreenShareSettingsResponse.inputSettings, url: newScreenShareUrl, css: customCssInput.value.trim() };
await sendRequest("SetInputSettings", { inputName: screenShareSourceName, inputSettings: updatedScreenShareSettings });
logMessage(`Screen share source '${screenShareSourceName}' updated with URL: ${newScreenShareUrl} and custom CSS.`, "logMessages.obs.screenShareSourceUpdatedWithUrlAndCss", { sourceName: screenShareSourceName, url: newScreenShareUrl });
}
} catch (error) { /* Suppress if source not found or other error */ }
}
logMessage("Codec/CSS update for OBS sources complete.", "logMessages.obs.codecCssUpdateComplete");
});
document.addEventListener("DOMContentLoaded", () => {
initializeApp();
updateLayoutConfigsListEmptyState();
});
</script>
</body>
</html>