351 lines
16 KiB
JavaScript
351 lines
16 KiB
JavaScript
(() => {
|
||
"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("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
|
||
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);
|
||
})();
|