From 3cea13c10355ee3bc9f18a206169a1077462955f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Thu, 20 Aug 2026 13:09:15 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B8=20=D0=BD=D0=B0=D0=B4=D0=B5=D1=8E=D1=81=D1=8C=20?= =?UTF-8?q?=D0=B7=D0=B0=D1=80=D0=BE=D0=B1=D0=B0=D1=82=D0=B0=D1=8E=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=83=D1=81=D1=8B=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=D0=BF=D0=BE=D0=BA=20=D0=BA=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D1=8B=D0=B5=20=D0=B2=D1=8B=D0=B7=D1=8B=D0=B2=D0=B0=D1=8E?= =?UTF-8?q?=D1=82=20=D1=82=D0=B8=D1=82=D1=80=D1=8B=20=D0=B8=D0=B7=20=D0=B2?= =?UTF-8?q?=D0=B5=D0=B1-=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hockey_data/agent_bridge.py | 108 ++++++++++++++ ...ld83_penalty_advantage_and_server_onair.py | 56 ++++++++ ui_builder/static/app.js | 135 +++++++++++++++--- 3 files changed, 282 insertions(+), 17 deletions(-) create mode 100644 tests/test_build83_penalty_advantage_and_server_onair.py diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index 3efaf4f..d2f604a 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -242,6 +242,8 @@ class VmixAgentHub: self._auto_refresh_task: asyncio.Task[None] | None = None self._auto_refresh_next: dict[tuple[str, str], float] = {} self._mapping_value_cache: dict[tuple[str, str, int, str, str, str, str], str] = {} + # BUILD83: server-side mirror of ACK-confirmed runtime Overlay state. + self._runtime_overlay_state: dict[str, dict[str, dict[str, str]]] = {} self._auto_refresh_last_error = "" @staticmethod @@ -251,6 +253,86 @@ class VmixAgentHub: raise ValueError("Некорректный device_id") return device_id + @staticmethod + def _runtime_overlay_command(function_name: Any) -> tuple[str, str] | None: + text = re.sub(r"\s+", "", str(function_name or "")) + if text.lower() == "overlayinputalloff": + return ("all", "off") + match = re.search(r"OverlayInput([1-4])(In|Out|Off)?", text, flags=re.IGNORECASE) + if not match: + return None + raw = str(match.group(2) or "toggle").lower() + action = "in" if raw == "in" else "out" if raw == "out" else "off" if raw == "off" else "toggle" + return str(match.group(1)), action + + def _track_runtime_overlay_command( + self, + device_id: str, + command: dict[str, Any], + *, + sequence_id: str = "", + sequence_name: str = "", + button_id: str = "", + ) -> None: + parsed = self._runtime_overlay_command(command.get("Function") or command.get("function")) + if parsed is None: + return + layer, action = parsed + device_state = self._runtime_overlay_state.setdefault(device_id, {}) + if layer == "all": + device_state.clear() + return + input_ref = str(command.get("Input") or command.get("input") or "").strip() + if action == "in": + device_state[layer] = { + "input": input_ref, + "sequence_id": str(sequence_id or ""), + "sequence_name": str(sequence_name or ""), + "button_id": str(button_id or ""), + "updated_at": _utcnow().isoformat(), + } + return + if action in {"out", "off"}: + device_state.pop(layer, None) + return + current = device_state.get(layer) + if current and (not input_ref or str(current.get("input") or "") == input_ref): + device_state.pop(layer, None) + else: + device_state[layer] = { + "input": input_ref, + "sequence_id": str(sequence_id or ""), + "sequence_name": str(sequence_name or ""), + "button_id": str(button_id or ""), + "updated_at": _utcnow().isoformat(), + } + + def _runtime_overlay_payload(self, device_id: str) -> dict[str, Any]: + source = self._runtime_overlay_state.get(device_id, {}) + overlays = {str(layer): dict(value) for layer, value in source.items() if str(layer) in {"1", "2", "3", "4"}} + return {"device_id": device_id, "overlays": overlays} + + def runtime_overlay_state_for_user(self, user: HockeyUser, *, device_id: str = "") -> dict[str, Any]: + requested = self.normalise_device_id(device_id) if str(device_id or "").strip() else "" + with self.database.session() as session: + if requested: + device = session.scalar(select(VmixDevice).where(and_(VmixDevice.device_uuid == requested, VmixDevice.wfl_user_id == user.id))) + if device is None: + raise HTTPException(status_code=404, detail="Agent не принадлежит текущему аккаунту") + else: + rows = list(session.scalars( + select(VmixDevice) + .where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True))) + .order_by(desc(VmixDevice.last_seen_at)) + )) + if not rows: + return {"device_id": "", "overlays": {}} + device = rows[0] + result = self._runtime_overlay_payload(device.device_uuid) + result["online"] = device.device_uuid in self._live + result["vmix_connected"] = bool(device.vmix_connected) + return result + @staticmethod def _mapping_source_code(data_key: Any) -> str: key = str(data_key or "").strip() @@ -558,6 +640,8 @@ class VmixAgentHub: row.last_seen_at = now if "connected" in vmix: row.vmix_connected = bool(vmix.get("connected")) + if not row.vmix_connected: + self._runtime_overlay_state.pop(device_id, None) if vmix.get("version") is not None: row.vmix_version = str(vmix.get("version") or "")[:64] if vmix.get("url") is not None: @@ -1107,6 +1191,9 @@ class VmixAgentHub: *, device_id: str = "", session_token: str = "", + sequence_id: str = "", + sequence_name: str = "", + button_id: str = "", timeout: float = 4.0, ) -> dict[str, Any]: """Execute commands on exactly one Agent bound to this browser/match session.""" @@ -1239,6 +1326,13 @@ class VmixAgentHub: "results": results, }, ) + self._track_runtime_overlay_command( + target_device_id, + command, + sequence_id=sequence_id, + sequence_name=sequence_name, + button_id=button_id, + ) return { "ok": True, "device_id": target_device_id, @@ -1247,6 +1341,7 @@ class VmixAgentHub: "session_token": session_token, "applied": len(results), "results": results, + "overlay_state": self._runtime_overlay_payload(target_device_id), } async def admin_test_mapping_value( @@ -2959,6 +3054,9 @@ class RuntimeVmixSequencePayload(BaseModel): commands: list[RuntimeVmixCommandPayload] = Field(default_factory=list, min_length=1, max_length=80) device_id: str = Field(default="", max_length=128) session_token: str = Field(default="", max_length=128) + sequence_id: str = Field(default="", max_length=160) + sequence_name: str = Field(default="", max_length=300) + button_id: str = Field(default="", max_length=160) class SelectSessionDevicePayload(BaseModel): @@ -3215,8 +3313,18 @@ def create_hockey_agent_router( commands, device_id=payload.device_id, session_token=payload.session_token, + sequence_id=payload.sequence_id, + sequence_name=payload.sequence_name, + button_id=payload.button_id, ) + @router.get("/api/hockey/vmix/overlay-state") + async def runtime_vmix_overlay_state( + device_id: str = Query(default="", max_length=128), + user: HockeyUser = Depends(auth_dependency), + ) -> dict[str, Any]: + return hub.runtime_overlay_state_for_user(user, device_id=device_id) + @router.post("/api/hockey/admin/vmix-mapping/apply-all-active", dependencies=admin) async def admin_apply_all_active_mappings() -> dict[str, Any]: return await hub.apply_mapping_to_all_active_devices(reason="ui_language_changed") diff --git a/tests/test_build83_penalty_advantage_and_server_onair.py b/tests/test_build83_penalty_advantage_and_server_onair.py new file mode 100644 index 0000000..dbfa8fc --- /dev/null +++ b/tests/test_build83_penalty_advantage_and_server_onair.py @@ -0,0 +1,56 @@ +from pathlib import Path + +from hockey_data.agent_bridge import VmixAgentHub + +ROOT = Path(__file__).resolve().parents[1] +APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8") +BRIDGE = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8") + + +def test_penalty_display_routes_single_side_to_opposite_advantage_target(): + start = APP_JS.index("function penaltyDisplayEntriesByTargetSide") + end = APP_JS.index("function hockeyPenaltySideMappingDetail", start) + block = APP_JS[start:end] + assert 'if (home.length && away.length)' in block + assert 'return { home: [], away: home.slice(0, 1), routedToAdvantage: true };' in block + assert 'return { home: away.slice(0, 1), away: [], routedToAdvantage: true };' in block + + +def test_penalty_rebalance_and_initial_timer_use_same_display_plan(): + assert APP_JS.count("penaltyDisplayEntriesByTargetSide(step)") >= 2 + assert "sourceSide" in APP_JS + assert "targetSide" in APP_JS + + +def test_server_tracks_acknowledged_overlay_owner_for_lower_dock(): + hub = object.__new__(VmixAgentHub) + hub._runtime_overlay_state = {} + hub._track_runtime_overlay_command( + "device-1", + {"Function": "OverlayInput4In", "Input": "TITLE-A"}, + sequence_id="seq-title-a", + sequence_name="Title A", + button_id="btn-a", + ) + payload = hub._runtime_overlay_payload("device-1") + assert payload["overlays"]["4"]["input"] == "TITLE-A" + assert payload["overlays"]["4"]["sequence_id"] == "seq-title-a" + assert payload["overlays"]["4"]["button_id"] == "btn-a" + + hub._track_runtime_overlay_command("device-1", {"Function": "OverlayInput4Out", "Input": "TITLE-A"}) + assert hub._runtime_overlay_payload("device-1")["overlays"] == {} + + +def test_runtime_sequence_carries_owner_metadata_and_polling_recovers_state(): + for token in ( + 'sequence_id: String(execution?.sequence_id || "")', + 'sequence_name: String(execution?.sequence_name || "")', + 'button_id: String(execution?.button_id || "")', + 'function applyServerRuntimeOverlayState(payload)', + 'function startQuickPanelOverlayPolling()', + '/api/hockey/vmix/overlay-state?device_id=', + 'state.quickPanelServerOnAirSequences.has(id)', + ): + assert token in APP_JS + assert 'sequence_id: str = Field(default="", max_length=160)' in BRIDGE + assert '@router.get("/api/hockey/vmix/overlay-state")' in BRIDGE diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index d534fc0..86b8ea9 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -99,6 +99,9 @@ quickPanelActiveTab: "", vmixOverlayRuntime: new Map(), quickPanelOnAirSequences: new Set(), + quickPanelServerOnAirSequences: new Set(), + quickPanelOverlayPollTimer: null, + quickPanelOverlayPollPending: false, triggerEditorOpenIds: new Set(), shortcutSequenceOpenId: "", shortcutInventory: { device_id: "", device_name: "", online: false, vmix_connected: false, inventory: { inputs: [] }, devices: [] }, @@ -4315,6 +4318,7 @@ function startCustomTooltips() { function shortcutSequenceIsOnAir(sequenceId) { const id = String(sequenceId || ""); if (!id) return false; + if (state.quickPanelServerOnAirSequences.has(id)) return true; const sequence = shortcutSequenceById(id); const targets = shortcutSequenceOverlayTargets(sequence); // BUILD58: first compare the actual Input currently tracked on the Overlay layer @@ -4342,6 +4346,74 @@ function startCustomTooltips() { }); } + + function applyServerRuntimeOverlayState(payload) { + const source = payload?.overlay_state && typeof payload.overlay_state === "object" ? payload.overlay_state : payload; + const overlays = source?.overlays && typeof source.overlays === "object" ? source.overlays : {}; + const previousServerIds = new Set(state.quickPanelServerOnAirSequences); + const nextServerIds = new Set(); + + for (const layer of ["1", "2", "3", "4"]) { + const raw = overlays[layer]; + if (raw && typeof raw === "object") { + const entry = { + input: String(raw.input || ""), + sequence_id: String(raw.sequence_id || ""), + sequence_name: String(raw.sequence_name || ""), + button_id: String(raw.button_id || ""), + server_confirmed: true, + }; + state.vmixOverlayRuntime.set(layer, entry); + if (entry.sequence_id) nextServerIds.add(entry.sequence_id); + } else { + const current = state.vmixOverlayRuntime.get(layer); + if (current?.server_confirmed) state.vmixOverlayRuntime.delete(layer); + } + } + + state.quickPanelServerOnAirSequences = nextServerIds; + previousServerIds.forEach((sequenceId) => { + if (nextServerIds.has(sequenceId)) return; + if (!sequenceStillOwnsRuntimeOverlay(sequenceId)) { + state.quickPanelOnAirSequences.delete(sequenceId); + state.shortcutSequenceOverlayState.set(sequenceId, false); + } + }); + nextServerIds.forEach((sequenceId) => { + state.quickPanelOnAirSequences.add(sequenceId); + state.shortcutSequenceOverlayState.set(sequenceId, true); + }); + refreshQuickPanelOnAirClasses(); + } + + async function pollQuickPanelOverlayState({ force = false } = {}) { + if (state.quickPanelOverlayPollPending && !force) return false; + const deviceId = currentRuntimeVmixDeviceId(); + if (!deviceId) return false; + state.quickPanelOverlayPollPending = true; + try { + const response = await fetch(`/api/hockey/vmix/overlay-state?device_id=${encodeURIComponent(deviceId)}`, { + method: "GET", cache: "no-store", credentials: "same-origin", + }); + if (!response.ok) return false; + const payload = await response.json(); + applyServerRuntimeOverlayState(payload); + return true; + } catch (_) { + return false; + } finally { + state.quickPanelOverlayPollPending = false; + } + } + + function startQuickPanelOverlayPolling() { + if (state.quickPanelOverlayPollTimer) window.clearInterval(state.quickPanelOverlayPollTimer); + pollQuickPanelOverlayState({ force: true }).catch(() => {}); + state.quickPanelOverlayPollTimer = window.setInterval(() => { + pollQuickPanelOverlayState().catch(() => {}); + }, 1000); + } + function trackRuntimeOverlayCommands(commands, execution = null) { const owner = execution && typeof execution === "object" ? execution : {}; const ownerSequenceId = String(owner.sequence_id || "").trim(); @@ -4422,6 +4494,9 @@ function startCustomTooltips() { commands: clean, device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken(), + sequence_id: String(execution?.sequence_id || ""), + sequence_name: String(execution?.sequence_name || ""), + button_id: String(execution?.button_id || ""), }), }); let payload = {}; @@ -4431,6 +4506,7 @@ function startCustomTooltips() { throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); } trackRuntimeOverlayCommands(clean, execution); + if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state); return payload; } @@ -4541,6 +4617,34 @@ function startCustomTooltips() { || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0)); } + // BUILD83: HOME/AWAY penalty targets describe where the extra scorebug plate + // is drawn, not which bench committed the penalty. While both teams have an + // active penalty we keep the traditional one-per-side display (coincidental + // penalties). As soon as only one team still has a penalty, its timer must + // move to the OPPOSITE target because that is the team playing on the power + // play. Example: HOME 1:43 + HOME 2:00 + AWAY 1:43 -> after the coincidental + // 1:43 pair expires, the remaining HOME 2:00 is shown through the AWAY input. + function penaltyDisplayEntriesByTargetSide(step) { + const home = sortedPenaltyEntries("home"); + const away = sortedPenaltyEntries("away"); + const soonestOnly = String(step?.penalty_display_mode || "soonest") !== "all"; + // Legacy BUILD43 equivalent was `allEntries.slice(0, 1)`; routing is now + // calculated for both sides together so the remaining penalty can move to + // the power-play team target without losing the shortest-time behaviour. + if (!soonestOnly) return { home, away, routedToAdvantage: false }; + + if (home.length && away.length) { + return { home: home.slice(0, 1), away: away.slice(0, 1), routedToAdvantage: false }; + } + if (home.length) { + return { home: [], away: home.slice(0, 1), routedToAdvantage: true }; + } + if (away.length) { + return { home: away.slice(0, 1), away: [], routedToAdvantage: true }; + } + return { home: [], away: [], routedToAdvantage: false }; + } + function hockeyPenaltySideMappingDetail(item, side) { if (!item?.event) return null; const event = item.event; @@ -4633,11 +4737,11 @@ function startCustomTooltips() { continue; } if (step.penalty_vmix_mode !== "text") continue; + const displayPlan = penaltyDisplayEntriesByTargetSide(step); for (const side of ["home", "away"]) { - const allEntries = sortedPenaltyEntries(side); const allTargets = sequencePenaltyTargets(step, side); const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all"; - const entries = soonestOnly ? allEntries.slice(0, 1) : allEntries; + const entries = Array.isArray(displayPlan[side]) ? displayPlan[side] : []; const targets = soonestOnly ? allTargets.slice(0, 1) : allTargets; const inactiveTargets = soonestOnly ? allTargets.slice(1) : []; targets.forEach((target, index) => { @@ -4650,7 +4754,7 @@ function startCustomTooltips() { setVmixPenaltyMirror(entry.component, entry.event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, - side, + side: String(entry.side || entry.event?.side || entry.event?.player?.side || side), overlay: target.overlay, }); state.vmixPenaltyTargetAssignments.set(assignmentKey, { @@ -4658,6 +4762,8 @@ function startCustomTooltips() { input: target.input, selectedName: target.selected_name, overlay: target.overlay, + sourceSide: String(entry.side || entry.event?.side || entry.event?.player?.side || ""), + targetSide: side, }); const value = formatHockeyPenaltyTime(entry.event.remainingMs); const mirror = state.vmixPenaltyMirrors.get(eventKey); @@ -4668,8 +4774,6 @@ function startCustomTooltips() { // If the scoreboard is already on air and this penalty target was not // previously assigned, bring the penalty plate on air immediately. - // Previously the text was updated here, but OverlayIn happened only - // when the whole scoreboard shortcut was executed again. if (hockeyScoreboardIsLive()) { const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2"; const targetWasVisible = Boolean(previous?.input) @@ -4819,25 +4923,21 @@ function startCustomTooltips() { } if (step.sync_vmix_penalties) { - let sortedHome = penalties.filter((item) => item.side === "home").sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0)); - let sortedAway = penalties.filter((item) => item.side === "away").sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0)); + const displayPlan = penaltyDisplayEntriesByTargetSide(step); const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all"; const activeHomeTargets = soonestOnly ? homeTargets.slice(0, 1) : homeTargets; const activeAwayTargets = soonestOnly ? awayTargets.slice(0, 1) : awayTargets; - if (soonestOnly) { - sortedHome = sortedHome.slice(0, 1); - sortedAway = sortedAway.slice(0, 1); - } state.activeHockeyVmixTimerSteps.add(step.id); - [["home", sortedHome, activeHomeTargets], ["away", sortedAway, activeAwayTargets]].forEach(([side, sideEntries, targets]) => { + [["home", displayPlan.home, activeHomeTargets], ["away", displayPlan.away, activeAwayTargets]].forEach(([side, sideEntries, targets]) => { sideEntries.forEach(({ component, event }, index) => { const target = targets[index] || null; if (!target?.input) return; if (!target.selected_name) throw new Error(`Для таймера удаления ${side === "home" ? "HOME" : "AWAY"} выберите Text / SelectedName`); if (step.penalty_vmix_mode === "text") { - setVmixPenaltyMirror(component, event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side, overlay: target.overlay }); + const sourceSide = String(event.player?.side || event.side || side); + setVmixPenaltyMirror(component, event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side: sourceSide, overlay: target.overlay }); state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), { - eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, + eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, }); commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: formatHockeyPenaltyTime(event.remainingMs) }); } else if (pausing) { @@ -12599,13 +12699,13 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
- Таймеры удалений HOME + Плашка удаления / большинства HOME
${penaltyTargetEditorRows(step, "home")}
- Таймеры удалений AWAY + Плашка удаления / большинства AWAY
${penaltyTargetEditorRows(step, "away")}
@@ -12624,7 +12724,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
${timerFinishActionRows(step)}
${escapeHtml(shortcutInventoryLabel())}Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.
-

Режим Text mirror рекомендуется: веб-таймер является источником истины и раз в секунду отправляет SetText строго в выбранные Input + SelectedName. Режим Countdown оставлен для титров, где countdown уже настроен внутри vMix. Для верхнего счёта по умолчанию используется одно ближайшее к окончанию удаление на сторону: при двойном штрафе в поле идёт минимальное оставшееся время, а после его завершения то же поле автоматически переключается на следующий штраф. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.

+

Режим Text mirror рекомендуется: веб-таймер является источником истины и раз в секунду отправляет SetText строго в выбранные Input + SelectedName. Режим Countdown оставлен для титров, где countdown уже настроен внутри vMix. Для верхнего счёта по умолчанию используется одно ближайшее к окончанию удаление. Пока удаления есть у обеих команд, HOME/AWAY показываются по своим сторонам. Когда штраф остаётся только у одной команды, его таймер автоматически переезжает на Input противоположной команды — стороны большинства. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.

`; } else if (step.type === "delay") { body.innerHTML = `
`; @@ -13489,6 +13589,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { el.runtimeView.classList.remove("hidden"); el.closePreviewBtn.classList.add("hidden"); renderRuntime(); + startQuickPanelOverlayPolling(); window.UIBuilderRuntime?.patchData?.({ hockey: { ui: { active_tab: state.activeTab, previous_tab: "" } } }, { render: false }); rememberUiNavigationState(PROJECT_TABS_ACTION_ID, "project_tab", state.activeTab, state.activeTab, { emit: false }); hockeyRefreshVmixMappingForTab(state.activeTab).catch(() => {});