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