bulid 63
This commit is contained in:
350
hockey_data/static/agent-devices.js
Normal file
350
hockey_data/static/agent-devices.js
Normal 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("<", "<")
|
||||
.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);
|
||||
})();
|
||||
Reference in New Issue
Block a user