471 lines
26 KiB
JavaScript
471 lines
26 KiB
JavaScript
(() => {
|
||
"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);
|
||
});
|
||
})();
|