(() => { "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 = '

Управление матчем

' + '
' + '

Матчем управляет один оператор. Остальные участники могут смотреть и получать данные.

' + '
' + '
' + '' + '' + '' + '' + '' + '' + '
' + ''; 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); }); })();