Files
hockey_new/hockey_data/static/match-access.js

471 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
"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 = '<span class="hockey-access-dot" aria-hidden="true"></span><span>Права на матч</span><small data-access-badge></small>';
(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 = '<header><h2 id="hockeyMatchAccessTitle">Управление матчем</h2>'
+ '<button type="button" data-access-close aria-label="Закрыть">×</button></header>'
+ '<p id="hockeyMatchAccessDescription">Матчем управляет один оператор. Остальные участники могут смотреть и получать данные.</p>'
+ '<div class="hockey-access-status"><strong data-access-role></strong><span data-access-owner></span></div>'
+ '<div class="hockey-access-actions">'
+ '<button type="button" data-access-action data-access-claim>Принять управление</button>'
+ '<button type="button" data-access-action data-access-resume hidden>Продолжить управление здесь</button>'
+ '<button type="button" data-access-action data-access-force hidden>Принять как администратор</button>'
+ '<select data-access-target aria-label="Участник для передачи управления" hidden></select>'
+ '<button type="button" data-access-action data-access-transfer hidden>Передать управление</button>'
+ '<button type="button" data-access-action data-access-release hidden>Завершить управление</button>'
+ '<button type="button" data-access-view>Продолжить как зритель</button></div>'
+ '<span data-access-message role="status" aria-live="polite"></span>';
document.body.appendChild(root);
trigger.onclick = openForm;
root.querySelector("[data-access-close]").onclick = closeForm;
root.querySelector("[data-access-view]").onclick = closeForm;
root.addEventListener("cancel", event => { event.preventDefault(); closeForm(); });
root.addEventListener("click", event => {
if (event.target !== root) return;
const rect = root.getBoundingClientRect();
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) closeForm();
});
root.querySelector("[data-access-target]").onchange = paint;
root.querySelector("[data-access-claim]").onclick = () => action("claim", {});
root.querySelector("[data-access-resume]").onclick = () => action("resume");
root.querySelector("[data-access-force]").onclick = () => {
if (window.confirm("Принять управление? Текущий оператор станет зрителем.")) action("claim", { force: true });
};
root.querySelector("[data-access-release]").onclick = () => action("release");
root.querySelector("[data-access-transfer]").onclick = () => {
const target = root.querySelector("[data-access-target]").value;
if (target) action("transfer", { participant_id: target });
};
paint();
}
window.HockeyMatchAccess = Object.freeze({
canControl, selectGame, beforeGameChange, finishOpening, storage, paint: paintControls,
isPeriodPending: () => periodPending > 0,
isWritePending: () => queueSize > 0,
refresh: poll,
snapshot: () => access ? { ...access } : null,
});
document.addEventListener("DOMContentLoaded", mount);
if (document.readyState !== "loading") mount();
setInterval(poll, 5000);
document.addEventListener("visibilitychange", () => { if (!document.hidden) poll(); });
window.addEventListener("online", poll);
// Block match shortcuts while leaving native form controls keyboard-operable.
window.addEventListener("keydown", event => {
if (!root?.open) return;
event.stopImmediatePropagation();
if (event.key === "Escape") { event.preventDefault(); closeForm(); }
}, true);
window.addEventListener("hockey:game-selected", event => {
const next = String(event.detail?.game?.external_id || event.detail?.game?.id || "");
if (next && next !== game) selectGame(next).catch(() => {});
});
// Viewer navigation and read filters remain available. Mutation entry points
// also check canControl() internally; this captures drag/drop and controls.
["click", "change", "input", "dragstart", "drop"].forEach(type => {
document.addEventListener(type, event => {
if (!canControl() && event.target.closest?.(mutatingControls)) {
event.preventDefault(); event.stopImmediatePropagation();
}
}, true);
});
})();