This commit is contained in:
2026-08-19 15:08:39 +03:00
parent 478557f3ad
commit a03c54dbe8
5148 changed files with 31553313 additions and 0 deletions

View File

@@ -0,0 +1,350 @@
(() => {
"use strict";
const boot = window.UI_BUILDER_BOOT || {};
if (boot.mode !== "runtime") return;
const selectedDeviceStorage = "hockey.vmix.selected_device";
const state = {
devices: [],
activeDeviceId: "",
activeDeviceIds: [],
selectedDeviceId: localStorage.getItem(selectedDeviceStorage) || "",
root: null,
panelOpen: false,
loading: false,
timer: null,
message: "",
messageError: false,
testOpenDeviceId: "",
testCommand: { input: "", selectedName: "", value: "" },
};
const esc = (value) => String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
async function api(path, options = {}) {
const response = await fetch(path, {
cache: "no-store",
credentials: "same-origin",
...options,
headers: {
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
});
let payload = {};
try { payload = await response.json(); } catch (_) {}
if (!response.ok) {
const detail = typeof payload.detail === "string"
? payload.detail
: payload.detail?.message || `HTTP ${response.status}`;
throw new Error(detail);
}
return payload;
}
function runtimeData() {
return window.UIBuilderRuntime?.getData?.() || {};
}
function currentGameId() {
const data = runtimeData();
return String(data.hockey?.selected_game?.external_id || data.hockey?.selected_game?.id || "").trim();
}
function currentSessionToken() {
return String(runtimeData().hockey?.operator_session?.token || "").trim();
}
function activeDevices() {
return state.devices.filter((item) => item.paired_to_me && item.active_for_account);
}
function selectedDevice() {
return state.devices.find((item) => (
item.device_id === state.selectedDeviceId
&& item.paired_to_me
&& item.active_for_account
)) || null;
}
function selectLocalDevice(deviceId) {
state.selectedDeviceId = String(deviceId || "").trim();
if (state.selectedDeviceId) localStorage.setItem(selectedDeviceStorage, state.selectedDeviceId);
else localStorage.removeItem(selectedDeviceStorage);
window.dispatchEvent(new CustomEvent("hockey:agent-selected", {
detail: { device_id: state.selectedDeviceId },
}));
}
async function bindSelectedDevice(deviceId) {
selectLocalDevice(deviceId);
const token = currentSessionToken();
if (!token) return null;
return api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/select-session`, {
method: "POST",
body: JSON.stringify({ session_token: token }),
});
}
function setMessage(text, error = false) {
state.message = text || "";
state.messageError = error;
render();
if (text) setTimeout(() => {
if (state.message === text) {
state.message = "";
render();
}
}, 3500);
}
function ensureRoot() {
if (state.root?.isConnected) return state.root;
const root = document.createElement("section");
root.className = "hockey-agent-ui";
root.innerHTML = `
<button class="hockey-agent-chip" type="button" data-agent-toggle aria-expanded="false" aria-label="vMix Agent" title="vMix Agent">
<span class="hockey-agent-dot"></span>
</button>
<div class="hockey-agent-panel" data-agent-panel hidden></div>
`;
const toolbar = document.querySelector(".runtime-topbar .runtime-actions");
if (toolbar) toolbar.prepend(root);
else document.body.appendChild(root);
root.querySelector("[data-agent-toggle]")?.addEventListener("click", () => {
state.panelOpen = !state.panelOpen;
render();
});
root.addEventListener("click", onClick);
root.addEventListener("input", (event) => {
const field = event.target.closest("[data-agent-test-field]");
if (!field) return;
const key = field.dataset.agentTestField;
if (key === "input") state.testCommand.input = field.value;
if (key === "selectedName") state.testCommand.selectedName = field.value;
if (key === "value") state.testCommand.value = field.value;
});
state.root = root;
return root;
}
async function onClick(event) {
const button = event.target.closest("button[data-agent-action]");
if (!button || state.loading) return;
const action = button.dataset.agentAction;
const deviceId = button.dataset.deviceId || "";
if (!deviceId) return;
state.loading = true;
render();
try {
if (action === "pair") {
await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/pair`, {
method: "POST",
body: JSON.stringify({ make_active: true }),
});
await bindSelectedDevice(deviceId);
setMessage("Agent прикреплён и выбран для этой панели");
} else if (action === "activate") {
await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/activate`, { method: "POST" });
await bindSelectedDevice(deviceId);
setMessage("Agent включён и выбран для этой панели");
} else if (action === "select") {
await bindSelectedDevice(deviceId);
setMessage("Эта панель теперь управляет выбранным Agent");
} else if (action === "deactivate") {
await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/deactivate`, { method: "POST" });
if (state.selectedDeviceId === deviceId) selectLocalDevice("");
setMessage("Получение данных для Agent остановлено");
} else if (action === "test-toggle") {
state.testOpenDeviceId = state.testOpenDeviceId === deviceId ? "" : deviceId;
} else if (action === "test-set-text") {
const input = state.testCommand.input.trim();
const selectedName = state.testCommand.selectedName.trim();
if (!input || !selectedName) throw new Error("Заполните Input и имя текстового поля");
const result = await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/test-set-text`, {
method: "POST",
body: JSON.stringify({ input, selected_name: selectedName, value: state.testCommand.value }),
});
setMessage(`SetText выполнен · матч ${result.match_id}`);
} else if (action === "unpair") {
if (!window.confirm("Отвязать этот agent от вашего аккаунта?")) return;
await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/pair`, { method: "DELETE" });
if (state.testOpenDeviceId === deviceId) state.testOpenDeviceId = "";
if (state.selectedDeviceId === deviceId) selectLocalDevice("");
setMessage("Agent отвязан");
}
await load(true);
} catch (error) {
setMessage(error.message || "Ошибка Agent", true);
} finally {
state.loading = false;
render();
}
}
async function load(force = false) {
if (state.loading && !force) return;
try {
const payload = await api("/api/hockey/agents/devices");
state.devices = Array.isArray(payload.devices) ? payload.devices : [];
state.activeDeviceId = String(payload.active_device_id || "");
state.activeDeviceIds = Array.isArray(payload.active_device_ids)
? payload.active_device_ids.map(String)
: (state.activeDeviceId ? [state.activeDeviceId] : []);
const selectedStillValid = state.devices.some((item) => (
item.device_id === state.selectedDeviceId
&& item.paired_to_me
&& item.active_for_account
));
if (!selectedStillValid) {
const mine = activeDevices();
if (mine.length === 1) selectLocalDevice(mine[0].device_id);
else if (state.selectedDeviceId) selectLocalDevice("");
}
const editingTest = Boolean(state.root?.querySelector("[data-agent-test-form]")?.contains(document.activeElement));
if (!editingTest) render();
} catch (error) {
if (!String(error.message || "").includes("401")) console.warn("Could not load vMix agents", error);
}
}
function deviceCard(item) {
const online = Boolean(item.online);
const vmix = Boolean(item.vmix_connected);
const mine = Boolean(item.paired_to_me);
const active = Boolean(item.active_for_account);
const selectedHere = state.selectedDeviceId === item.device_id;
const free = item.pair_state === "free";
const busy = item.pair_state === "busy";
const currentMatch = String(item.current_match_id || "");
let actions = "";
if (free && online) {
actions = `<button type="button" data-agent-action="pair" data-device-id="${esc(item.device_id)}">Прикрепить</button>`;
} else if (mine) {
const canTest = active && selectedHere && online && vmix && currentMatch;
actions = `
${active
? '<span class="hockey-agent-badge active">Получает данные</span>'
: `<button type="button" data-agent-action="activate" data-device-id="${esc(item.device_id)}">Включить</button>`}
${active
? (selectedHere
? '<span class="hockey-agent-badge active">Эта панель</span>'
: `<button type="button" data-agent-action="select" data-device-id="${esc(item.device_id)}">Использовать здесь</button>`)
: ""}
${canTest ? `<button class="secondary" type="button" data-agent-action="test-toggle" data-device-id="${esc(item.device_id)}">${state.testOpenDeviceId === item.device_id ? "Скрыть тест" : "Тест SetText"}</button>` : ""}
${active ? `<button class="secondary" type="button" data-agent-action="deactivate" data-device-id="${esc(item.device_id)}">Не получать</button>` : ""}
<button class="danger" type="button" data-agent-action="unpair" data-device-id="${esc(item.device_id)}">Отвязать</button>
`;
} else if (busy) {
actions = '<span class="hockey-agent-badge busy">Занято другим оператором</span>';
}
const testForm = state.testOpenDeviceId === item.device_id ? `
<div class="hockey-agent-test" data-agent-test-form>
<div class="hockey-agent-test-title"><strong>Тест Web → Agent → vMix</strong><small>SetText</small></div>
<label>Input <input data-agent-test-field="input" value="${esc(state.testCommand.input)}" placeholder="например Scorebug"></label>
<label>Поле <input data-agent-test-field="selectedName" value="${esc(state.testCommand.selectedName)}" placeholder="например HomeTeam.Text"></label>
<label>Текст <input data-agent-test-field="value" value="${esc(state.testCommand.value)}" placeholder="TEST FROM WEB"></label>
<button type="button" data-agent-action="test-set-text" data-device-id="${esc(item.device_id)}">Отправить SetText</button>
<small>Тест выполняется только на этом Agent и его текущем матче.</small>
</div>` : "";
return `
<article class="hockey-agent-device ${online ? "is-online" : "is-offline"} ${active ? "is-active" : ""} ${selectedHere ? "is-selected-here" : ""}">
<div class="hockey-agent-device-title">
<span class="hockey-agent-status-light ${online ? "online" : "offline"}"></span>
<div><strong>${esc(item.name || item.hostname || item.device_id)}</strong><code>${esc(item.device_id)}</code></div>
</div>
<div class="hockey-agent-device-grid">
<span>Agent <b>${online ? "ONLINE" : "OFFLINE"}</b></span>
<span>vMix <b class="${vmix ? "ok" : "warn"}">${vmix ? "CONNECTED" : "NO CONNECTION"}</b></span>
${currentMatch ? `<span>Матч <b>${esc(currentMatch)}</b></span>` : ""}
${item.mapping ? `<span>Mapping <b class="ok">${esc(item.mapping.name)} · v${esc(item.mapping.version)}</b></span>` : (item.project_fingerprint ? `<span>Mapping <b class="warn">НЕ НАСТРОЕН</b></span>` : "")}
${item.project_input_count ? `<span>vMix структура <b>${esc(item.project_input_count)} Inputs · ${esc(item.project_field_count || 0)} полей</b></span>` : ""}
${item.agent_version ? `<span>Версия <b>${esc(item.agent_version)}</b></span>` : ""}
</div>
<div class="hockey-agent-actions">${actions}</div>
${testForm}
</article>
`;
}
function render() {
const root = ensureRoot();
const chip = root.querySelector("[data-agent-toggle]");
const panel = root.querySelector("[data-agent-panel]");
if (!chip || !panel) return;
const active = activeDevices();
const selected = selectedDevice();
const freeOnline = state.devices.filter((item) => item.online && item.pair_state === "free").length;
root.classList.toggle("has-active", Boolean(selected?.online));
root.classList.toggle("has-vmix", Boolean(selected?.online && selected?.vmix_connected));
root.classList.toggle("is-offline", Boolean(selected && !selected.online));
let chipTitle = "vMix Agent · не подключён";
if (selected) {
const name = selected.name || selected.hostname || "Agent";
const vmixState = selected.vmix_connected ? "vMix подключён" : "vMix не подключён";
chipTitle = `${name} · ${vmixState}${selected.current_match_id ? ` · матч ${selected.current_match_id}` : ""}`;
} else if (active.length > 1) {
chipTitle = `vMix Agent · ${active.length} активных · выберите устройство`;
} else if (freeOnline) {
chipTitle = `vMix Agent · ${freeOnline} свободных устройств`;
}
chip.title = chipTitle;
chip.setAttribute("aria-label", chipTitle);
chip.setAttribute("aria-expanded", state.panelOpen ? "true" : "false");
panel.hidden = !state.panelOpen;
if (!state.panelOpen) return;
const gameId = currentGameId();
const cards = state.devices.length
? state.devices.map(deviceCard).join("")
: `<div class="hockey-agent-empty">Активных agent.exe пока нет.<br>Запустите agent.exe на компьютере с vMix.</div>`;
panel.innerHTML = `
<header>
<div><small>VMIX BRIDGE</small><strong>Устройства</strong></div>
<button type="button" class="icon" data-agent-close aria-label="Закрыть">×</button>
</header>
<div class="hockey-agent-current-match">Web матч: <b>${esc(gameId || "не выбран")}</b></div>
${state.message ? `<div class="hockey-agent-notice ${state.messageError ? "error" : ""}">${esc(state.message)}</div>` : ""}
<div class="hockey-agent-list">${cards}</div>
<footer>Активных Agent может быть несколько. F1, Space, таймеры и другие команды идут только в Agent с меткой «Эта панель».</footer>
`;
panel.querySelector("[data-agent-close]")?.addEventListener("click", () => {
state.panelOpen = false;
render();
});
}
window.HockeyAgentRuntime = {
currentDeviceId: () => String(state.selectedDeviceId || ""),
selectDevice: (deviceId) => { selectLocalDevice(deviceId); render(); },
getDevices: () => state.devices.slice(),
};
window.addEventListener("hockey:game-selected", () => {
setTimeout(() => load(true), 300);
setTimeout(() => load(true), 1300);
});
window.addEventListener("focus", () => load(true));
document.addEventListener("visibilitychange", () => {
if (!document.hidden) load(true);
});
ensureRoot();
load(true);
state.timer = window.setInterval(() => {
if (!document.hidden) load();
}, 3000);
})();