(() => {
"use strict";
const nativeFetch = window.fetch.bind(window);
// getRandomValues also works on a local HTTP panel outside localhost.
const id = () => Array.from(crypto.getRandomValues(new Uint8Array(16)),
byte => byte.toString(16).padStart(2, "0")).join("");
const participantKey = "hockey.matchParticipant";
let participant = sessionStorage.getItem(participantKey) || id();
sessionStorage.setItem(participantKey, participant);
const instance = id();
const tabKeys = new Set(["hockey.selectedGameId", "hockey.selectedTournamentId", "hockey.gameDate",
"hockey.operatorSessionToken", "hockey.vmix.selected_device"]);
const storage = {
getItem(key) {
if (!tabKeys.has(key)) return localStorage.getItem(key);
const value = sessionStorage.getItem(key);
if (value !== null) return value;
const migrationKey = `hockey.tabMigrated:${key}`;
if (sessionStorage.getItem(migrationKey)) return null;
sessionStorage.setItem(migrationKey, "1");
const old = localStorage.getItem(key);
if (old !== null) sessionStorage.setItem(key, old);
return old;
},
setItem(key, value) {
if (tabKeys.has(key)) sessionStorage.setItem(`hockey.tabMigrated:${key}`, "1");
(tabKeys.has(key) ? sessionStorage : localStorage).setItem(key, value);
},
removeItem(key) {
if (tabKeys.has(key)) sessionStorage.setItem(`hockey.tabMigrated:${key}`, "1");
(tabKeys.has(key) ? sessionStorage : localStorage).removeItem(key);
},
};
// Duplicated tabs inherit sessionStorage. Keep the original controller and
// rotate the newcomer before it makes any requests with the inherited id.
const channel = typeof BroadcastChannel === "function" ? new BroadcastChannel("hockey.match-participants") : null;
if (channel) {
channel.onmessage = ({ data }) => {
if (data.participant !== participant || data.instance === instance) return;
if (data.type === "probe") channel.postMessage({ type: "occupied", participant, instance, target: data.instance });
if (data.type === "occupied" && data.target === instance) {
participant = id();
sessionStorage.setItem(participantKey, participant);
}
};
channel.postMessage({ type: "probe", participant, instance });
}
const ready = new Promise(resolve => setTimeout(resolve, channel ? 120 : 0));
let game = "", access = null, leaseDeadline = 0, suspended = true, epoch = 0;
let queue = Promise.resolve(), queueSize = 0, periodPending = 0, polling = false;
let lastJoin = 0, lastHeartbeat = 0, root = null, trigger = null, selectPromise = null, busy = false;
let opening = false, openingFailed = false, selectionVersion = 0;
const device = () => String(storage.getItem("hockey.vmix.selected_device") || "");
const canControl = (gameId = game) => Boolean(game && String(gameId) === game && !suspended
&& access?.can_control && performance.now() < leaseDeadline);
const mutation = (path, method) => !["GET", "HEAD", "OPTIONS"].includes(method) && (
/^\/api\/hockey\/games\/[^/]+\/(?:control\/(?:period|timers|flags|values|score)|lines|custom-pluses|shootout(?:\/|$))/.test(path)
|| (/^\/api\/hockey\/context\//.test(path) && !path.endsWith("/resolve")));
const command = (path, method) => !["GET", "HEAD", "OPTIONS"].includes(method) && (
path === "/api/hockey/vmix/sequence" || /\/test-set-text$/.test(path)
|| /^\/api\/hockey\/prepared-titles(?:\/\d+(?:\/preview)?)?$/.test(path));
const denied = (message, status = 403) => new Response(JSON.stringify({ detail: { code: "view_only", message } }),
{ status, headers: { "Content-Type": "application/json" } });
function headers(initial = {}) {
const result = new Headers(initial);
result.set("X-Hockey-Participant", participant);
result.set("X-Hockey-Game", game);
result.set("X-Hockey-Control-Generation", String(access?.generation || 0));
result.set("X-Hockey-Device", device());
return result;
}
function notice(message) {
if (root) root.querySelector("[data-access-message]").textContent = message;
window.dispatchEvent(new CustomEvent("hockey:access-notice", { detail: { message } }));
}
function accept(value) {
if (!value || value.game_id !== game || value.participant_id !== participant) return false;
if (access && (Number(value.generation) < Number(access.generation)
|| Number(value.revision) < Number(access.revision))) return false;
const changed = access && (access.generation !== value.generation || access.role !== value.role);
if (changed) epoch += 1;
const oldRevision = Number(access?.revision || 0);
access = { ...value, revision: Math.max(oldRevision, Number(value.revision || 0)) };
leaseDeadline = performance.now() + Math.max(0, Number(value.lease_remaining_ms || 0) - 5000);
if (!value.can_control) suspended = true;
if (changed) window.dispatchEvent(new CustomEvent("hockey:access-changed", { detail: access }));
paint();
return true;
}
async function api(action = "", body) {
await ready;
const selected = game;
const version = selectionVersion;
const response = await nativeFetch(`/api/hockey/games/${encodeURIComponent(selected)}/access${action ? `/${action}` : ""}`, {
method: action ? "POST" : "GET", cache: "no-store", credentials: "same-origin",
headers: headers({ "Content-Type": "application/json" }),
...(body !== undefined ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(5000),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail?.message || payload.detail || `HTTP ${response.status}`);
if (selected === game && version === selectionVersion && payload.game_id) accept(payload);
return payload;
}
async function syncControl() {
if (!game) return;
const selected = game;
const version = selectionVersion;
const language = storage.getItem("hockey.language") === "en" ? "en" : "ru";
const response = await nativeFetch(`/api/hockey/games/${encodeURIComponent(game)}/control?language=${language}`, {
cache: "no-store", credentials: "same-origin", headers: headers(), signal: AbortSignal.timeout(5000),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail?.message || payload.detail || `HTTP ${response.status}`);
if (selected !== game || version !== selectionVersion || !accept(payload.control_access)) return;
// Restore authority before rendering the snapshot, so newly created
// controls do not remember a temporary viewer state as their own disabled state.
suspended = !access?.can_control;
window.dispatchEvent(new CustomEvent("hockey:control-snapshot", { detail: { game_id: game, control: payload } }));
paint();
}
async function recover(message) {
epoch += 1;
suspended = true;
paint();
try { await syncControl(); } catch (_) { /* Fail closed until a successful refresh. */ }
notice(message);
}
window.fetch = async (input, init = {}) => {
const url = new URL(input instanceof Request ? input.url : String(input), location.href);
if (url.origin !== location.origin || !url.pathname.startsWith("/api/hockey")) return nativeFetch(input, init);
await ready;
const method = String(init.method || (input instanceof Request ? input.method : "GET")).toUpperCase();
const isMutation = mutation(url.pathname, method), isCommand = command(url.pathname, method);
const targetGame = /^\/api\/hockey\/games\/([^/]+)\//.exec(url.pathname)?.[1];
if ((isMutation || isCommand) && !canControl(targetGame ? decodeURIComponent(targetGame) : game)) {
return denied("Режим зрителя: доступны просмотр и получение данных");
}
const selected = game, capturedEpoch = epoch, capturedGeneration = access?.generation, version = selectionVersion;
const isPeriod = /\/control\/period$|\/shootout\/setup$/.test(url.pathname) && isMutation;
if (isPeriod) periodPending += 1;
const run = async () => {
if ((isMutation || isCommand) && (selected !== game || capturedEpoch !== epoch
|| capturedGeneration !== access?.generation || !canControl())) {
return denied("Управление или состояние матча изменилось; повторите действие", 409);
}
const requestHeaders = headers(init.headers || (input instanceof Request ? input.headers : {}));
if (isMutation) {
requestHeaders.set("X-Hockey-Revision", String(access?.revision || 0));
requestHeaders.set("X-Hockey-Command-Id", id());
}
try {
const response = await nativeFetch(input, { ...init, headers: requestHeaders });
if (url.pathname === "/api/hockey/me" && response.ok) {
const profile = await response.clone().json();
const previous = sessionStorage.getItem("hockey.participantAccount");
if (previous && previous !== String(profile.id)) {
participant = id();
sessionStorage.setItem(participantKey, participant);
epoch += 1; game = ""; access = null; suspended = true;
}
sessionStorage.setItem("hockey.participantAccount", String(profile.id));
}
// Do not process stale reads from the previous match.
if (selected === game && version === selectionVersion && (isMutation || /\/control(?:\?|$)/.test(url.pathname))) {
let payload = {};
try { payload = await response.clone().json(); } catch (_) {}
if (!isMutation && response.ok && Number(payload.revision) < Number(access?.revision || 0)) {
return denied("Получен устаревший ответ; ожидается актуальное состояние", 409);
}
if (response.ok && payload.control_access) accept(payload.control_access);
if ((isMutation || isCommand) && [401, 403, 409, 428].includes(response.status)) {
await recover(payload.detail?.message || "Доступ изменился; состояние матча обновлено");
}
} else if (selected === game && isCommand && !response.ok) {
let payload = {}; try { payload = await response.clone().json(); } catch (_) {}
if ([401, 403].includes(response.status) || ["control_changed", "participant_required", "version_required"].includes(payload.detail?.code)) {
await recover("Команда отклонена: проверьте право управления и выбранный Agent");
}
}
return response;
} catch (error) {
if (isMutation || isCommand) await recover("Связь прервалась. Проверьте состояние перед повтором действия");
throw error;
}
};
if (!isMutation) return run();
// Ordered state writes share one revision; native Countdown delivery keeps
// its independent fast path and is never put behind this database queue.
queueSize += 1;
const queued = queue.catch(() => {}).then(run).finally(() => {
queueSize -= 1;
if (isPeriod) periodPending -= 1;
});
queue = queued.catch(() => {});
return queued;
};
async function beforeGameChange(next) {
if (!game || String(next) === game) return;
closeForm();
if (canControl()) {
await window.UIBuilderRuntime?.flushGameTimers?.();
await queue;
try { await api("release"); } catch (_) { /* Remaining lease expires on the server. */ }
}
try { await api("leave"); } catch (_) { /* Presence also expires if disconnected. */ }
epoch += 1;
selectionVersion += 1;
suspended = true;
access = null;
game = "";
paint();
}
async function selectGame(gameId, { waitForWorkspace = false } = {}) {
gameId = String(gameId || "");
if (selectPromise && game === gameId) return selectPromise;
await beforeGameChange(gameId);
closeForm();
selectionVersion += 1;
game = gameId;
access = null;
suspended = true;
opening = waitForWorkspace;
openingFailed = false;
notice("");
paint();
const task = (async () => {
try {
// Device validation belongs to the actual assignment. Joining as a
// spectator remains possible with a stale local device selection.
try { await api("join", { device_id: device() }); }
catch (_) { await api("join", {}); }
lastJoin = Date.now();
if (access && !access.can_control) openForm();
await syncControl();
return access;
} catch (error) { notice(error.message); throw error; }
})();
selectPromise = task;
try { return await task; } finally { if (selectPromise === task) selectPromise = null; }
}
function finishOpening(gameId, success = true) {
if (String(gameId) !== game) return;
opening = false;
openingFailed = !success;
if (!success) {
suspended = true;
notice("Не удалось открыть матч. Повторите его открытие перед управлением.");
}
paint();
}
async function action(name, payload) {
if (busy || opening || openingFailed) return;
busy = true;
notice("");
if (root) root.setAttribute("aria-busy", "true");
try {
if (["transfer", "release"].includes(name)) {
await window.UIBuilderRuntime?.flushGameTimers?.();
await queue;
}
suspended = true;
epoch += 1;
paint();
if (["claim", "resume"].includes(name)) {
try { await api("join", { device_id: device() }); }
catch (_) { await api("join", {}); }
}
await api(name, payload);
await syncControl();
if (name === "resume" && canControl() && access.control_device_id) {
storage.setItem("hockey.vmix.selected_device", access.control_device_id);
window.HockeyAgentRuntime?.selectDevice?.(access.control_device_id);
}
} catch (error) {
await recover(error.message);
} finally { busy = false; root?.removeAttribute("aria-busy"); paint(); }
}
async function poll() {
if (!game || polling || selectPromise || busy || opening || openingFailed) return;
polling = true;
try {
const hadControl = canControl();
if (access?.can_control && Date.now() - lastHeartbeat >= 20000) {
await api("heartbeat");
lastHeartbeat = Date.now();
} else {
await api();
}
if (Date.now() - lastJoin >= 20000) {
try { await api("join", { device_id: device() }); } catch (_) { await api("join", {}); }
lastJoin = Date.now();
}
if ((access?.can_control && !hadControl) || suspended && access?.can_control) await syncControl();
} catch (error) {
if (access?.can_control) {
suspended = true;
epoch += 1;
window.dispatchEvent(new CustomEvent("hockey:access-changed", { detail: { ...access, can_control: false } }));
}
try {
await syncControl();
notice(access && !access.can_control ? "Управление изменилось. Эта вкладка работает как зритель." : "");
} catch (_) {
notice("Нет подтверждения связи. Управление временно заблокировано");
}
} finally { polling = false; paint(); }
}
const mutatingControls = [
"[data-match-write]", "[data-hockey-current-period]", "[data-quick-command-button]",
"[data-player-panel-title-button]", "[data-hockey-score-command]", "[data-quick-selector-runtime]",
"[data-quick-timer-toggle]", "[data-quick-hockey-toggle]", "#quickTimersBtn",
".hcp-workspace input", ".hcp-workspace select", ".hcp-workspace textarea", ".hcp-workspace button",
".hso-workspace button", ".hso-workspace select:not([data-shootout-search])",
".hpd-toolbar button", ".hpd-penalty-actions button", ".hockey-line-player", ".hockey-line-dropzone",
".hockey-lines-workspace button", ".hockey-prepared-workspace button[data-prepared-create]",
].join(",");
function paintControls(scope = document) {
const allowed = canControl();
document.documentElement.classList.toggle("hockey-view-only", !allowed);
scope.querySelectorAll(mutatingControls).forEach(node => {
if (!allowed && !node.hasAttribute("data-access-disabled")) {
node.dataset.accessDisabled = String(Boolean(node.disabled));
node.disabled = true;
node.setAttribute("aria-disabled", "true");
} else if (allowed && node.hasAttribute("data-access-disabled")) {
node.disabled = node.dataset.accessDisabled === "true";
node.removeAttribute("data-access-disabled");
node.removeAttribute("aria-disabled");
}
});
}
function paint() {
if (!root) return;
trigger.hidden = !game;
if (!game) closeForm();
const allowed = canControl();
const checking = !access || access.can_control && !allowed;
root.dataset.role = allowed ? "controller" : checking ? "checking" : "viewer";
trigger.dataset.role = root.dataset.role;
trigger.querySelector("[data-access-badge]").textContent = allowed ? "Оператор" : checking ? "Проверка связи" : "Зритель";
trigger.setAttribute("aria-label", `Права на матч: ${allowed ? "вы управляете" : checking ? "проверка доступа" : "только просмотр"}`);
root.querySelector("[data-access-role]").textContent = allowed ? "Вы управляете матчем" : checking ? "Проверяем доступ к управлению…" : "Вы открыли матч как зритель";
root.querySelector("[data-access-owner]").textContent = access?.owner
? `Оператор: ${access.owner.name}${access.can_resume ? " · ваш аккаунт в другой вкладке" : ""}` : access ? "Управление свободно" : "Загружаем состояние матча";
root.querySelector("#hockeyMatchAccessDescription").textContent = access?.can_resume
? "Управление закреплено за другой вкладкой вашего аккаунта. Нажмите «Продолжить управление здесь», чтобы перенести его в эту вкладку. Предыдущая вкладка станет зрителем."
: "Матчем управляет один оператор. В режиме зрителя доступны просмотр и получение данных, команды управления заблокированы.";
root.querySelector("[data-access-claim]").hidden = !access?.can_claim;
root.querySelector("[data-access-resume]").hidden = !access?.can_resume;
root.querySelector("[data-access-force]").hidden = !access?.can_force_claim || !access?.owner || access?.can_resume;
root.querySelector("[data-access-release]").hidden = !allowed;
const select = root.querySelector("[data-access-target]");
select.hidden = !allowed;
root.querySelector("[data-access-transfer]").hidden = !allowed;
root.querySelector("[data-access-view]").hidden = allowed || checking;
root.querySelectorAll("[data-access-action]").forEach(button => { button.disabled = busy || opening || openingFailed; });
select.disabled = busy || opening || openingFailed;
if (document.activeElement !== select) {
const previous = select.value;
select.replaceChildren(new Option("Передать участнику…", ""));
for (const item of access?.participants || []) {
if (item.participant_id !== participant && item.can_claim) select.add(new Option(item.name, item.participant_id));
}
select.value = previous;
}
root.querySelector("[data-access-transfer]").disabled = busy || opening || openingFailed || !select.value;
paintControls();
}
function closeForm() {
if (!root?.open) return;
if (typeof root.close === "function") root.close();
else root.removeAttribute("open");
trigger?.setAttribute("aria-expanded", "false");
trigger?.focus({ preventScroll: true });
}
function openForm() {
if (!game || !root || root.open) return;
paint();
if (typeof root.showModal === "function") root.showModal();
else root.setAttribute("open", "");
trigger.setAttribute("aria-expanded", "true");
root.querySelector("[data-access-close]").focus({ preventScroll: true });
void poll();
}
function mount() {
const topbar = document.querySelector(".runtime-topbar");
if (!topbar || root) return;
trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "hockey-match-access-trigger";
trigger.hidden = true;
trigger.dataset.accessOpen = "true";
trigger.setAttribute("aria-haspopup", "dialog");
trigger.setAttribute("aria-controls", "hockeyMatchAccessDialog");
trigger.setAttribute("aria-expanded", "false");
trigger.innerHTML = 'Права на матч';
(topbar.querySelector(".runtime-actions") || topbar).prepend(trigger);
root = document.createElement("dialog");
root.id = "hockeyMatchAccessDialog";
root.className = "hockey-match-access";
root.setAttribute("aria-labelledby", "hockeyMatchAccessTitle");
root.setAttribute("aria-describedby", "hockeyMatchAccessDescription");
root.innerHTML = 'Управление матчем
'
+ '
Матчем управляет один оператор. Остальные участники могут смотреть и получать данные.
' + '