Files
archived-vdo.ninja/obs/index.html
2025-10-22 01:45:17 -03:00

4540 lines
210 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" />
<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;
}
.container:nth-of-type(7) .collapsible-content {
padding-top: 2px !important;
}
.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;
gap: 5px !important;
}
.stream-item .add-stream-btn,
.stream-item .highlight-btn,
.stream-item .screen-share-btn {
padding: 4px 8px !important;
font-size: 0.85em !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;
}
.layout-config-item > .flex-row {
align-items: center !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>
let logEntries = [];
const languageSelector = document.getElementById('languageSelector');
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;
let initialSelectedSourceCreationScenes = [];
window.translation = {};
window.miscTranslations = {};
function getTranslation(key, params = null) {
if (!window.translation) {
if (typeof params === 'string') return params;
return key;
}
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 (typeof params === 'string') return params;
return key;
}
}
if (!value) {
if (typeof params === 'string') return params;
return key;
}
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;
}
async function initializeApp() {
let savedLanguage = localStorage.getItem('vdoNinjaObsControlLanguage') || 'en';
languageSelector.value = savedLanguage;
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 updateContent() {
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);
}
});
});
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');
});
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();
}
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));
});
}
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);
}
});
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");
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 = [];
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();
});
});
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();
}
});
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function logMessage(fallbackMessage, i18nKey, i18nParams = {}) {
const timestamp = new Date().toLocaleTimeString();
logEntries.push({
timestamp,
i18nKey,
i18nParams,
fallbackMessage
});
if (logEntries.length > 200) {
logEntries.shift();
}
renderLog();
}
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) {
}
const safeTranslatedMessage = translatedMessage.replace(/</g, "&lt;").replace(/>/g, "&gt;");
logContent += `[${entry.timestamp}] ${safeTranslatedMessage}\n`;
});
logArea.innerHTML = logContent;
logArea.scrollTop = logArea.scrollHeight;
}
function updateLayoutConfigsListEmptyState() {
if (layoutConfigsListDiv && layoutConfigsListDiv.children.length === 0) {
layoutConfigsListDiv.classList.add('is-empty');
} else if (layoutConfigsListDiv) {
layoutConfigsListDiv.classList.remove('is-empty');
}
}
function generateRequestId(type) {
return `${type}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
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);
});
}
function getVdoNinjaBaseUrl() {
const customUrl = vdoNinjaBaseUrlInput.value.trim();
return customUrl || "https://vdo.ninja";
}
function getFullCameraPrefix() {
return cameraPrefixInput.value.trim();
}
function getFullReactionPrefix() {
const camPrefix = getFullCameraPrefix();
const reactSub = reactionSubPrefixInput.value.trim();
return reactSub ? `${camPrefix}.${reactSub}` : camPrefix;
}
function getFullHighlightPrefix() {
const camPrefix = getFullCameraPrefix();
const hlSub = highlightSubPrefixInput.value.trim();
return hlSub ? `${camPrefix}.${hlSub}` : camPrefix;
}
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;
}
cameraPrefixInput.addEventListener("input", () => {
updatePrefixLabels();
saveSettings();
});
reactionSubPrefixInput.addEventListener("input", () => {
updatePrefixLabels();
saveSettings();
});
highlightSubPrefixInput.addEventListener("input", () => {
updatePrefixLabels();
saveSettings();
});
function toggleVdoNinjaInputs(disabled) {
vdoNinjaBaseUrlInput.disabled = disabled;
vdoNinjaRoomInput.disabled = disabled;
vdoNinjaPasswordInput.disabled = disabled;
vdoNinjaStreamIdsInput.disabled = disabled;
}
function updateObsConnectButtonText() {
if (obsConnectBtn && window.translation) {
obsConnectBtn.textContent = obsConnected ? getTranslation("obsConnection.disconnectButton") : getTranslation("obsConnection.connectButton");
}
}
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) {
}
return { width: defaultWidth, height: defaultHeight };
}
async function hideSourceItem(sceneName, sceneItemId, sourceNameForLog = "Unknown") {
if (!sceneName || !sceneItemId) return;
try {
await sendRequest("SetSceneItemEnabled", {
sceneName: sceneName,
sceneItemId: sceneItemId,
sceneItemEnabled: false
});
} catch(e) {
logMessage(`Error hiding source item ${sourceNameForLog} in ${sceneName}: ${e.message}`, "logMessages.errorHidingSource", {sourceName: sourceNameForLog, sceneName, message: e.message});
}
}
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);
}
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");
});
function saveSettings() {
const selectedSourceCreationOptions = [];
const checkboxes = document.querySelectorAll('#sourceCreationScenesContainer input[type="checkbox"]:checked');
checkboxes.forEach(checkbox => selectedSourceCreationOptions.push(checkbox.value));
initialSelectedSourceCreationScenes = selectedSourceCreationOptions;
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();
}
function disconnectFromVdoNinja() {
vdoNinjaIframe.src = "about:blank";
if (vdoNinjaConnectionCheckTimer) {
clearTimeout(vdoNinjaConnectionCheckTimer);
vdoNinjaConnectionCheckTimer = null;
}
activeStreams = {};
updateStreamList();
updateVdoNinjaButtonState(false);
logMessage("Disconnected from VDO.Ninja.", "logMessages.vdoNinja.disconnected");
}
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();
}
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);
});
}
function setupStreamMappingUI() {
const addStreamMappingBtn = document.getElementById(
"addStreamMappingBtn"
);
addStreamMappingBtn.addEventListener("click", () => {
addNewStreamMapping();
});
loadStreamMappings();
}
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}`);
}
}
}
}
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;
}
function setupLayoutConfigUI() {
if (addLayoutConfigBtn) {
addLayoutConfigBtn.addEventListener('click', () => {
addNewLayoutConfigUI();
updateLayoutConfigsListEmptyState();
});
}
loadLayoutConfigs();
}
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;
}
function saveLayoutConfigsAndUpdateScene(configId) {
saveLayoutConfigs();
const changedConfig = sceneLayoutConfigs.find(c => c.id === configId);
if (changedConfig && changedConfig.sceneName) {
triggerLayoutUpdateForScene(changedConfig.sceneName);
if (changedConfig.layoutType === 'grid') {
const changedGridSceneName = changedConfig.sceneName;
sceneLayoutConfigs.forEach(otherConfig => {
if (otherConfig.id !== configId &&
(otherConfig.layoutType === 'reaction' || otherConfig.layoutType === 'highlight') &&
otherConfig.sceneName) {
const firstPotentialGridFallback = sceneLayoutConfigs.find(cfg => cfg.layoutType === 'grid' && cfg.sceneName);
if (firstPotentialGridFallback && firstPotentialGridFallback.sceneName === changedGridSceneName) {
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);
}
}
});
}
}
}
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;
}
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.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) {
const isSceneStillUsed = sceneLayoutConfigs.some(cfg =>
cfg.sceneName === sceneOfRemovedLayout
);
if (!isSceneStillUsed) {
const defaultCheckbox = document.querySelector(`#sourceCreationScenesContainer input[type="checkbox"][value="${CSS.escape(sceneOfRemovedLayout)}"]`);
if (defaultCheckbox && !defaultCheckbox.checked) {
defaultCheckbox.checked = true;
saveSettings();
}
}
}
if (sceneOfRemovedLayout) {
triggerLayoutUpdateForScene(sceneOfRemovedLayout);
}
updateLayoutConfigsListEmptyState();
updateSceneDropdowns();
});
let previousSceneName = config.sceneName || "";
sceneSelect.addEventListener('change', (event) => {
const newSceneName = event.target.value;
const oldSceneName = previousSceneName;
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;
}
if (newSceneName) {
const newDefaultCheckbox = document.querySelector(`#sourceCreationScenesContainer input[type="checkbox"][value="${CSS.escape(newSceneName)}"]`);
if (newDefaultCheckbox && newDefaultCheckbox.checked) {
newDefaultCheckbox.checked = false;
saveSettings();
}
}
previousSceneName = newSceneName;
saveLayoutConfigsAndUpdateScene(configId);
if (oldSceneName && oldSceneName !== newSceneName) {
const isOldSceneStillUsed = sceneLayoutConfigs.some(cfg =>
cfg.sceneName === oldSceneName
);
if (!isOldSceneStillUsed) {
const oldDefaultCheckbox = document.querySelector(`#sourceCreationScenesContainer input[type="checkbox"][value="${CSS.escape(oldSceneName)}"]`);
if (oldDefaultCheckbox && !oldDefaultCheckbox.checked) {
oldDefaultCheckbox.checked = true;
saveSettings();
}
}
}
updateSceneDropdowns();
});
layoutSelect.addEventListener('change', () => {
updateLayoutSpecificControls(configId, layoutSelect.value, controlsContainer, config);
saveLayoutConfigsAndUpdateScene(configId);
});
if (config.sceneName && obsScenes.some(s => s.sceneName === config.sceneName)) {
sceneSelect.value = config.sceneName;
const defaultCheckbox = document.querySelector(`#sourceCreationScenesContainer input[type="checkbox"][value="${CSS.escape(config.sceneName)}"]`);
if (defaultCheckbox && defaultCheckbox.checked) {
defaultCheckbox.checked = false;
saveSettings();
}
} else if (config.sceneName) {
logMessage(`Saved scene '${config.sceneName}' for layout not found in current OBS scenes.`, "logMessages.layoutSceneNotFound", {sceneName: config.sceneName});
}
return configId;
}
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);
}
});
}
}
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});
}
}
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();
}
function getLayoutConfigForScene(sceneName) {
return sceneLayoutConfigs.find(config => config.sceneName === sceneName);
}
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;
}
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);
});
}
}
}
}
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
);
}
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);
}
}
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();
}
}
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();
}
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();
}
});
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 };
}
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 = "";
}
}
});
}
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;
}
}
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;
}
}
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);
});
}
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 });
}
}
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();
}
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();
}
}
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);
});
}
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 = "";
}
}
}
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 = "";
}
}
function getTargetScene() {
const firstCheckedCheckbox = document.querySelector('#sourceCreationScenesContainer input[type="checkbox"]:checked');
return firstCheckedCheckbox ? firstCheckedCheckbox.value : "";
}
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;
}
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);
}
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
);
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);
}
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 firstConfiguredLayoutScene = (sceneLayoutConfigs.length > 0 && sceneLayoutConfigs[0].sceneName) ? sceneLayoutConfigs[0].sceneName : "";
const primaryTargetSceneName = resolvedTargetInfo.scene || (allSelectedSourceCreationScenes.length > 0 ? allSelectedSourceCreationScenes[0] : firstConfiguredLayoutScene) || "";
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,
});
}
const scenesToEnsureItemIn = new Set();
if (primaryTargetSceneName) {
scenesToEnsureItemIn.add(primaryTargetSceneName);
}
allSelectedSourceCreationScenes.forEach(sceneName => {
if (sceneName) scenesToEnsureItemIn.add(sceneName);
});
if (shouldCloneThisStreamToMainViaMapping && firstSourceCreationSceneFromCheckboxes) {
scenesToEnsureItemIn.add(firstSourceCreationSceneFromCheckboxes);
}
const scenesWithLayouts = sceneLayoutConfigs
.map(config => config.sceneName)
.filter(Boolean);
scenesWithLayouts.forEach(sceneName => {
if (sceneName) {
logMessage(`Adding source '${effectiveSourceName}' to scene '${sceneName}' because it has a layout configured.`, "logMessages.obs.addingSourceToLayoutScene", {sourceName: effectiveSourceName, sceneName: sceneName});
scenesToEnsureItemIn.add(sceneName);
}
});
for (const sceneName of scenesToEnsureItemIn) {
if (sceneName) {
logMessage(`Ensuring source item '${effectiveSourceName}' exists in scene '${sceneName}'.`, "logMessages.obs.ensuringSourceItemInScene", {sourceName: effectiveSourceName, sceneName: sceneName});
await ensureSourceInScene(sceneName, effectiveSourceName, canvasWidth, canvasHeight);
}
}
if (activeStreams[streamId]) activeStreams[streamId].sourceCreated = true;
// Disparar atualizações de layout para todas as cenas afetadas
scenesToEnsureItemIn.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();
}
}
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;
}
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);
}
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);
}
}
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});
}
}
}
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);
}
}
}
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]) {
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;
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];
}
let leftCameraAreaWidth = camsLeft.length > 0 ? canvasWidth * 0.15 : 0;
let rightCameraAreaWidth = camsRight.length > 0 ? canvasWidth * 0.15 : 0;
let baseScaleWidthForMainContent = canvasWidth;
baseScaleWidthForMainContent -= spacing;
baseScaleWidthForMainContent -= spacing;
if (leftCameraAreaWidth > 0) {
baseScaleWidthForMainContent -= leftCameraAreaWidth;
baseScaleWidthForMainContent -= spacing;
}
if (rightCameraAreaWidth > 0) {
baseScaleWidthForMainContent -= rightCameraAreaWidth;
baseScaleWidthForMainContent -= spacing;
}
let mainContentVisualWidth = Math.max(1, baseScaleWidthForMainContent);
let mainContentVisualHeight = mainContentVisualWidth / itemAspectRatio;
const availableHeightForScaling = canvasHeight - (2 * spacing);
if (mainContentVisualHeight > availableHeightForScaling) {
mainContentVisualHeight = Math.max(1, availableHeightForScaling);
mainContentVisualWidth = mainContentVisualHeight * itemAspectRatio;
}
const mainContentVisualX = spacing +
(leftCameraAreaWidth > 0 ? (leftCameraAreaWidth + spacing) : 0) +
offsetX;
const mainContentVisualY = (canvasHeight - mainContentVisualHeight) / 2 + offsetY;
if (mainSourceItem) {
const mainSrcInfo = await getInputSettingsSafe(mainSourceItem.sourceName, canvasWidth, canvasHeight);
const transformMain = {
alignment: 5, boundsType: "OBS_BOUNDS_NONE",
positionX: mainContentVisualX,
positionY: mainContentVisualY,
scaleX: mainSrcInfo.width === 0 ? 0 : mainContentVisualWidth / mainSrcInfo.width,
scaleY: mainSrcInfo.height === 0 ? 0 : mainContentVisualHeight / mainSrcInfo.height,
width: mainContentVisualWidth,
height: mainContentVisualHeight,
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();
let allObsScenes = [];
try {
logMessage(`Fetching all scenes from OBS to ensure complete removal of ${streamId}...`, "logMessages.obs.fetchingAllScenesForRemoval", { id: streamId });
const response = await sendRequest("GetSceneList");
if (response && response.scenes) {
allObsScenes = response.scenes;
logMessage(`Found ${allObsScenes.length} total scenes for cleanup check.`, "logMessages.obs.foundScenesForCleanup", { count: allObsScenes.length });
} else {
logMessage("Could not fetch full scene list for removal, cleanup might be incomplete.", "logMessages.obs.fetchSceneListForRemovalFailed");
}
} catch (error) {
logMessage(`Error fetching scene list for removal: ${error.message}. Cleanup might be incomplete.`, "logMessages.obs.errorFetchingSceneListForRemoval", { message: error.message });
}
for (const scene of allObsScenes) {
const sceneName = scene.sceneName;
if (!sceneName) continue;
let itemRemovedFromThisScene = false;
if (await tryRemoveFromScene(baseSourceName, sceneName)) {
itemRemovedFromThisScene = true;
}
if (highlightedSourceName !== baseSourceName) {
if (await tryRemoveFromScene(highlightedSourceName, sceneName)) {
itemRemovedFromThisScene = true;
}
}
if (itemRemovedFromThisScene) {
scenesToClean.add(sceneName);
}
}
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>